Bulk Sublicense Change
Enqueue an asynchronous bulk add/remove of sublicenses for a list of users, without changing their Panorama.
Adds and/or removes sublicenses for a set of users without changing their Panorama. Each entry in
users carries its own licenseIdsToAdd / licenseIdsToRemove, so one call can apply a different
license delta to each learner. 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-sublicense-assignmentThis endpoint is served by the v3 API. Its full path is
https://{instance}.thoughtindustries.com/v3/users/bulk-sublicense-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
enrollmentOptionsa license removal removes nothing: enrollments no remaining license grants 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
- Every license you name must already exist on the learner's Panorama.
licenseIdsToAddare validated against the licenses defined on the Panorama the learner is already in; the job does not create them. - Every learner in the batch must already be in a Panorama. A learner on the main site is reported as an individual failure.
- 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 license seat limit is still accepted with a
202; the job then fails as a whole and changes nothing. Check capacity before submitting. - Concurrent changes to the same learner are serialized. Each learner is processed under a per-user lock, so re-submitting a request after a failure is safe: a learner whose licenses already match the requested set is reported as an individual failure rather than processed twice. Two racing submissions are not two changes.
- Send real JSON booleans in
enrollmentOptions. String values such as"false"are coerced rather than rejected — see Enrollment options.
Example request — add and remove sublicenses
curl -X POST "https://{instance}.thoughtindustries.com/v3/users/bulk-sublicense-assignment" \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"users": [
{
"id": "3f9a2b18-7c4d-4e2a-9b1f-0c5d6e7f8a90",
"licenseIdsToAdd": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
"licenseIdsToRemove": ["b2c3d4e5-f6a7-8901-bcde-f23456789012"]
},
{
"email": "[email protected]",
"licenseIdsToAdd": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]
}
],
"enrollmentOptions": {
"preserveEnrollments": true,
"preserveProgress": true
}
}'Example request — remove a sublicense and permanently delete progress
When any user in the batch removes a license, enrollmentOptions is required so that
license-derived enrollments are not silently converted to direct enrollments. Omitting it returns 400,
and so does sending an empty enrollmentOptions: {} — at least one of the two fields must carry an
actual boolean to count as a decision.
The values below are the most destructive combination this endpoint accepts. For every course and
learning path the removed license was the learner's only route to, preserveEnrollments: false removes
their access and preserveProgress: false permanently deletes their progress, quiz attempts, SCORM
attempts, and assignment submissions for it. To remove access but keep progress, set
preserveEnrollments to false and leave preserveProgress at true; when no user in the batch
removes a license, omit enrollmentOptions entirely.
curl -X POST "https://{instance}.thoughtindustries.com/v3/users/bulk-sublicense-assignment" \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"users": [
{
"id": "3f9a2b18-7c4d-4e2a-9b1f-0c5d6e7f8a90",
"licenseIdsToRemove": ["b2c3d4e5-f6a7-8901-bcde-f23456789012"]
}
],
"enrollmentOptions": {
"preserveEnrollments": false,
"preserveProgress": false
}
}'Parameters
Requires the users.edit permission. A standard company API key satisfies this.
| Name | Type | Required | Location | Description |
|---|---|---|---|---|
users | BulkSublicenseUser[] | Yes | body | Users to update; each carries its own license add/remove sets. 1–5000 entries. |
enrollmentOptions | EnrollmentOptions | Conditional | body | Required when any user removes a license; optional otherwise. Applies to the whole batch. See Enrollment options. |
Each object in users (BulkSublicenseUser):
| Field | Type | Required | Description |
|---|---|---|---|
id | uuid | One of id / email / externalCustomerId | User to update, by ID. |
email | string | One of id / email / externalCustomerId | User to update, by email. |
externalCustomerId | string | One of id / email / externalCustomerId | User to update, by external customer ID. |
licenseIdsToAdd | uuid[] | No | Sublicenses to add for this user. Defaults to []. |
licenseIdsToRemove | uuid[] | No | Sublicenses to remove for this user. Defaults to []. |
At least one of licenseIdsToAdd / licenseIdsToRemove must be non-empty for each user. The user's
Panorama is never changed.
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 change. 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 applying both deltas — see Errors.
Enrollment options
enrollmentOptions decides what happens to enrollments that a license removal would otherwise drop. 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 a removed license and a retained or newly added one — 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 change 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. 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.
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 are not affected at all, since this endpoint never changes the learner's Panorama. 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 compare their license assignment against what you submitted. This is the only programmatic confirmation that an individual user was updated.
Two kinds of failure can still occur after the 202:
- Per-user failures leave the rest of the batch untouched — for example naming a license that does not belong to the learner's Panorama, a learner who is not in a Panorama at all, or a learner whose licenses already match the requested set.
- Whole-job failures change nothing. The job re-checks aggregate seat demand against 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. |
A user has neither licenseIdsToAdd nor licenseIdsToRemove | Each user must change at least one license. |
A license is removed without enrollmentOptions | Send enrollmentOptions whenever any user removes a license. An empty {} does not count — one of the two fields must carry a boolean. |
| 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 both deltas to one learner. metadata.duplicateIdentifiers lists the colliding identifiers. |
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 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, 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 | Not expected in normal use. This endpoint never changes a learner's Panorama, so it runs no destination-Panorama check and does not require the clients permission; for a Panorama-scoped manager, learners outside its scope are dropped during resolution and reported in unresolvedIdentifiers instead of being refused. The scope assertion that can still emit this code runs after resolution as defence in depth. Does not apply to a company API key, which is not Panorama-scoped. |
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
- To move learners between Panoramas rather than change their sublicenses, use
Bulk Panorama migration
(
POST /v3/users/bulk-panorama-assignment). - 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 change. 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.