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

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.

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

This endpoint is served by the v3 API. Its full path is https://{instance}.thoughtindustries.com/v3/users/bulk-sublicense-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 a license removal removes nothing: enrollments no remaining license grants 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

  • Every license you name must already exist on the learner's Panorama. licenseIdsToAdd are 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 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 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.

NameTypeRequiredLocationDescription
usersBulkSublicenseUser[]YesbodyUsers to update; each carries its own license add/remove sets. 1–5000 entries.
enrollmentOptionsEnrollmentOptionsConditionalbodyRequired when any user removes a license; optional otherwise. Applies to the whole batch. See Enrollment options.

Each object in users (BulkSublicenseUser):

FieldTypeRequiredDescription
iduuidOne of id / email / externalCustomerIdUser to update, by ID.
emailstringOne of id / email / externalCustomerIdUser to update, by email.
externalCustomerIdstringOne of id / email / externalCustomerIdUser to update, by external customer ID.
licenseIdsToAdduuid[]NoSublicenses to add for this user. Defaults to [].
licenseIdsToRemoveuuid[]NoSublicenses 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.

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. 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.

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 are not affected at all, since this endpoint never changes the learner's Panorama. 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 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:

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.
A user has neither licenseIdsToAdd nor licenseIdsToRemoveEach user must change at least one license.
A license is removed without enrollmentOptionsSend 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 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 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:

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_FORBIDDENAUTHORIZATIONNot 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.
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.

  • 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 as panoramaEnrollmentOptions, and additionally lets you name specific courses to retain through the change. 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.