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.
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 — 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 (enrollmentOptions required)
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.
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. 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 enrollments that a license removal would otherwise drop. 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 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. |
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. |
| No identifier resolves to a user | If every entry is unresolvable the request fails rather than enqueuing an empty job. |
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.