This guide describes how to import users into Mosaic from a JSON export of your existing user store. Bulk import is often combined with JIT migration. To choose a migration approach, see Migrate user store.
In bulk imports, user records are exported from an existing IDP and uploaded to Mosaic as a JSON file. Mosaic validates the records against its schema and creates users in its identity store. Imported users appear in the Admin Portal like any other Mosaic user.
Bulk import doesn't include roles, groups, or authenticators. After the import, assign roles and groups in Mosaic, and register Mosaic authenticators for imported users (for example, in a login journey).
The import flow:
- You request upload URLs from Mosaic (
POST /v1/migration/users/files/upload-urls). See Request upload URLs. - You upload each JSON file directly to object storage using its URL. See Upload each file.
- You start an import run (
POST /v1/migration/users/runs). Mosaic reads the files, validates the records, and creates users. See Step 5: Start the import. - You poll the run (
GET /v1/migration/users/runs/{run_id}) for totals and outcome files listing failed records. See Step 6: Track progress and review failures. - Optionally, you cancel a running import (
POST /v1/migration/users/runs/{run_id}/cancel) so you can start a new run. See Step 7: Cancel a run.
If this is your first time integrating with Mosaic, create an application in the Admin Portal as described here. Imported users are assigned to the application you pass when you start the run.
Bulk import APIs are tenant-level operations. Authorize every call with a client access token generated from a management app, not from the application you assign imported users to.
- In the Admin Portal, go to Settings > Management Apps.
- Click + Add management app, give it a name (for example,
Bulk user import), and save. - Open the app and copy the Client ID and Client Secret.
For more, see Leverage management apps.
Generate a client access token with those credentials:
curl -i -X POST \
'https://api.transmitsecurity.io/cis/oidc/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=client_credentials \
-d client_id=MANAGEMENT_APP_CLIENT_ID \
-d client_secret=MANAGEMENT_APP_CLIENT_SECRETBefore you transform the export, make sure Mosaic can store the attributes you plan to import.
- List the identifiers (such as email, phone number, or username) and other user attributes stored in your source user store. You'll map each of them to a Mosaic user field.
- Review the default Mosaic user schema.
- If the source uses attributes that Mosaic does not provide out of the box, add custom user fields (Admin Portal > B2C Identity > Users > Edit Schema).
Custom fields must exist in the tenant schema before you upload the file. Records that hold an undefined custom field fail validation and aren't created.
Export the source user store from your existing IDP, then transform it into a JSON array of user objects. Field order within each object does not matter. Field names and types must match Mosaic.
Each record follows the same structure as the Create user request body, with these constraints:
- Include at least one identifier (
email,phone_number,username, or a custom identifier you defined in the schema).external_user_idcan be included, but it does not count as an identifier. Records with onlyexternal_user_idfail validation and aren't created. - Identifiers must be unique across all records in the file and across existing Mosaic users (
usernameis unique per application). - Unknown fields are rejected.
- Do not include plaintext passwords in
credentials. - Do not include
delegated_access. - Put custom attributes in
custom_user_fields(subject to availability in your tenant). Those keys must already exist in the tenant schema (see Step 2).
Each record can also include these fields supported only for bulk import:
| Field | Description |
|---|---|
user_id | Optional Mosaic user ID to assign. If omitted, Mosaic generates one. A duplicate user_id fails at creation. Ability to provide user_id is subject to availability in your tenant. |
email_verified | Whether the primary email is already verified. |
email_verified_at | When the email was verified (ISO 8601). |
phone_verified | Whether the primary phone number is already verified. |
phone_verified_at | When the phone number was verified (ISO 8601). |
status | User status: Active, Disabled, or Pending. |
last_auth | Timestamp of the user's last authentication in the source system (ISO 8601). |
hashed_password | Pre-hashed password to store as-is. Mosaic verifies it at login using the given algorithm. Do not send a plaintext password. |
hashed_password_history | Array of previous password hashes in the same shape as hashed_password. Up to 20 entries. Requires hashed_password to be set. |
hashed_password (and each item in hashed_password_history) uses this object:
| Field | Description |
|---|---|
hash | The password hash string. |
algorithm | Algorithm used to produce the hash. Supported values: argon2, bcrypt, sha, sha1, sha2, sha256, sha512, ssha, ssha256, ssha512, md5, smd5, firebaseScrypt, drupalPhpass. |
salt | Salt used when hashing, if the algorithm requires it. |
force_replace | When true, the password is temporary and the user must replace it after a successful login. |
File names must match ^[a-zA-Z0-9][a-zA-Z0-9._-]*\.json$ (for example, users_eu_1.json) and cannot exceed 255 characters. A file cannot exceed 2 GB. A tenant can store up to 1000 files. Split a large export across multiple files if needed.
Example file (users_batch_1.json):
[
{
"user_id": "3cu0eCGdFIkAXLpcNQnPa",
"external_user_id": "ext-2000097700",
"external_account_id": "tPgBrCercegT",
"email": "max.planck.2000097700@example.com",
"email_verified": true,
"email_verified_at": "2025-05-25T05:57:42.813Z",
"phone_number": "+14153074425",
"phone_verified": true,
"phone_verified_at": "2025-12-28T22:50:33.147Z",
"username": "mplanck_2000097700",
"name": {
"title": "Ms.",
"first_name": "Max",
"middle_name": "Woodrow",
"last_name": "Planck"
},
"birthday": "1858-09-14",
"address": {
"country": "DE",
"state": "California",
"city": "New Randalfurt",
"street_address": "5354 America Passage",
"postal_code": "52372",
"type": "Other"
},
"picture": "https://avatars.githubusercontent.com/u/79465009",
"language": "de",
"status": "Disabled",
"last_auth": "2026-06-16T03:24:01.209Z",
"hashed_password": {
"hash": "$2b$10$sJZrc4geZiludsEJ3nxzg3eRSnTYPWeb66Ee1ZSkLxoxr6DBrTBm8",
"algorithm": "bcrypt",
"force_replace": false
},
"custom_app_data": {
"source": "poc",
"batch": 0,
"discipline": "science"
},
"hashed_password_history": [],
"custom_user_fields": {
"discipline": "science",
"birth_year": 1858,
"notable_work": "The quantum of action",
"is_nobel_laureate": true,
"scholar_id": "SCH-00X2T19G",
"first_publication": "1883-09-17T00:00:00.000Z",
"research_areas": [
"astrophysics"
],
"milestones": [
"1899-04-11T01:11:07.890Z"
],
"contact_email": "max.planck.2000097700@lab.example.com",
"lab_phone": "+12125557286",
"bio": {
"discipline": "science",
"born": 1858,
"known_for": "The quantum of action",
"verified": false
}
}
}
]Files are uploaded directly to object storage, not through the Mosaic API host. The upload takes two calls per batch of files:
- Request an upload URL for each file name from Mosaic.
- Upload each file to its URL, using the form fields Mosaic returned.
If a URL expires (after 15 minutes) or an upload is interrupted, request a new URL for the same file name and upload again.
Call POST /v1/migration/users/files/upload-urls with the file names you will upload. You can request up to 100 names in one call. Mosaic returns an upload URL, valid for 15 minutes, and form fields for each name. Re-requesting a name you already uploaded returns a URL that overwrites the same object.
curl -i -X POST \
'https://api.transmitsecurity.io/cis/v1/migration/users/files/upload-urls' \
-H 'Authorization: Bearer MANAGEMENT_APP_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"files": ["users_batch_1.json"]
}'Example response:
{
"result": {
"files": [
{
"name": "users_batch_1.json",
"url": "https://storage.example/upload",
"fields": {
"key": "user-migration/TENANT_ID/files/users_batch_1.json",
"policy": "..."
},
"expires_at": "2026-09-15T12:00:00.000Z"
}
]
}
}Upload each file with a multipart/form-data POST to the url returned for that file name. Include every entry in fields as a form field, then attach the file as the last field.
curl -i -X POST \
'UPLOAD_URL' \
-F 'key=OBJECT_KEY' \
-F 'policy=POLICY' \
-F 'file=@/path/to/users_batch_1.json'Use the url and fields values Mosaic returned. Add any additional fields entries the response includes.
Once the files have been uploaded to storage, you can run the import in one of two modes:
- Dry run (
dry_run): validates every record against the schema without creating users. Use it to find and fix validation errors before the real import. A dry run always reportscreatedas0. - Live (
live): validates every record and creates the users that pass validation.
To start a run:
curl -i -X POST \
'https://api.transmitsecurity.io/cis/v1/migration/users/runs' \
-H 'Authorization: Bearer MANAGEMENT_APP_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"mode": "live",
"app_id": "APP_ID",
"files": ["users_batch_1.json"]
}'mode:dry_runorlive.app_id: the application the imported users will be assigned to.files: the uploaded file names to process. If you omitfiles, the run processes every file currently uploaded for the tenant.
The response includes a run_id. A tenant can have only one active run at a time. Starting another run while one is running returns 409. To start a different run, cancel the active one first. Mosaic also rejects the run if a listed file is missing from storage or if app_id does not exist.
Created users appear in Admin Portal > B2C Identity > Users and through the Users APIs. Existing users are not updated.
Poll GET /v1/migration/users/runs/{run_id} until the run is no longer running:
curl -i -X GET \
'https://api.transmitsecurity.io/cis/v1/migration/users/runs/RUN_ID' \
-H 'Authorization: Bearer MANAGEMENT_APP_ACCESS_TOKEN'Example response:
{
"result": {
"run_id": "RUN_ID",
"status": "completed",
"mode": "live",
"total_files": 1,
"processed": 2,
"created": 1,
"failed": 1,
"started_at": "2026-09-22T12:00:00.000Z",
"completed_at": "2026-09-22T12:05:00.000Z",
"files": [
{
"name": "users_batch_1.json",
"status": "completed",
"processed": 2,
"created": 1,
"failed": 1,
"outcome_url": "https://storage.example/outcomes/users_batch_1.json"
}
]
}
}statusfor the run isrunning,completed,failed, orcancelled.completed_atis present only after the run ends.processed,created, andfailedare totals across every file.failedcovers records that did not match the schema (for example, a missing identifier, an invalid field, or a custom field that is not defined for the tenant) and records Mosaic could not create (for example, a duplicateduser_id,email,username, or other unique identifier).filesreports the same counters per uploaded file. Each file status ispending,running,completed, orfailed.outcome_urlis a download link valid for 24 hours. It appears after that file has completed. Use it to inspect each failed record and the reason.
Fix the failed records, upload a new file (or overwrite the same name), and start another run for the remaining users.
To stop a run that is still running, call POST /v1/migration/users/runs/{run_id}/cancel. Mosaic sets the run status to cancelled and does not process remaining work. Work already in progress finishes, and each file still gets a report. Users that were already created stay in the identity store.
After the run is cancelled, you can start a new run.
curl -i -X POST \
'https://api.transmitsecurity.io/cis/v1/migration/users/runs/RUN_ID/cancel' \
-H 'Authorization: Bearer MANAGEMENT_APP_ACCESS_TOKEN'Uploaded files are stored by Transmit for one month and then deleted automatically. Until then, you can reuse an uploaded file in later runs. As a best practice, delete files you no longer need.
List files:
curl -i -X GET \
'https://api.transmitsecurity.io/cis/v1/migration/users/files' \
-H 'Authorization: Bearer MANAGEMENT_APP_ACCESS_TOKEN'Delete one file:
curl -i -X DELETE \
'https://api.transmitsecurity.io/cis/v1/migration/users/files/users_batch_1.json' \
-H 'Authorization: Bearer MANAGEMENT_APP_ACCESS_TOKEN'Delete all uploaded files:
curl -i -X DELETE \
'https://api.transmitsecurity.io/cis/v1/migration/users/files' \
-H 'Authorization: Bearer MANAGEMENT_APP_ACCESS_TOKEN'You cannot delete files while a migration run is in progress.
Developed by Transmit Security engineers, this sample script processes up to 1 million user records in about 10 minutes after the files are uploaded. Upload time depends on your network connection and is not included in that 10 minutes. To use the script:
- Set values for
APP_IDandBASE_URLat the top of the script.APP_IDis the application imported users will be assigned to, not the management app. - Run the script with the management app client credentials and paths to the files:
CLIENT_ID=... CLIENT_SECRET=... node bulk-migrate-users.mjs ./users-1.json ./users-2.json
import { existsSync, openAsBlob } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
const BASE_URL = 'https://api.transmitsecurity.io/cis'; // Update according to your environment
const APP_ID = 'YOUR_APP_ID'; // Replace with your app ID
const MODE = 'dry_run'; // Change to 'live' to create the users. 'dry_run' only validates the files
const FILE_PATHS = process.argv.slice(2);
let accessToken;
let accessTokenExpiresAt = 0;
async function getAccessToken() {
if (Date.now() < accessTokenExpiresAt) {
return accessToken;
}
const response = await fetch(`${BASE_URL}/oidc/token`, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
}),
});
const token = await response.json();
accessToken = token.access_token;
accessTokenExpiresAt = Date.now() + (token.expires_in - 60) * 1000;
return accessToken;
}
async function callApi(method, path, body) {
const token = await getAccessToken();
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(`${method} ${path} failed with ${response.status}: ${responseText}`);
}
return JSON.parse(responseText).result;
}
// 1. Upload each file with its own presigned form
async function uploadFiles(filePaths) {
const missingFiles = filePaths.filter((filePath) => !existsSync(filePath));
if (filePaths.length === 0) {
throw new Error('Pass the files to upload: node bulk-migrate-users.mjs users-1.json users-2.json');
}
if (missingFiles.length > 0) {
throw new Error(`Files not found: ${missingFiles.join(', ')}`);
}
const fileNames = [];
for (const filePath of filePaths) {
const fileName = basename(filePath);
const file = await openAsBlob(filePath);
const startedAt = Date.now();
console.log(`Uploading ${fileName} (${(file.size / 1024 ** 2).toFixed(1)} MB)`);
const {
files: [uploadForm],
} = await callApi('POST', '/v1/migration/users/files/upload-urls', { files: [fileName] });
const form = new FormData();
for (const [field, value] of Object.entries(uploadForm.fields)) {
form.append(field, value);
}
form.append('file', file, fileName);
const upload = await fetch(uploadForm.url, { method: 'POST', body: form });
if (!upload.ok) {
throw new Error(`Uploading ${fileName} failed with ${upload.status}`);
}
console.log(`Uploaded ${fileName} in ${Math.round((Date.now() - startedAt) / 1000)}s`);
fileNames.push(fileName);
}
return fileNames;
}
// 2. Start a migration run on the uploaded files
async function startRun(fileNames) {
const { run_id: runId } = await callApi('POST', '/v1/migration/users/runs', {
mode: MODE,
app_id: APP_ID,
files: fileNames,
});
console.log(`Started run ${runId}`);
return runId;
}
// 3. Poll the run status until the run finishes
async function pollRunStatus(runId) {
while (true) {
const run = await callApi('GET', `/v1/migration/users/runs/${runId}`);
console.log(`${run.status}: processed ${run.processed}, created ${run.created}, failed ${run.failed}`);
if (run.status !== 'running') {
return run;
}
for (let seconds = 10; seconds > 0; seconds -= 1) {
process.stdout.write(`\rNext check in ${seconds}s `);
await sleep(1000);
}
process.stdout.write('\r');
}
}
// 4. Save the report of each file
async function saveReports(run) {
for (const { name, outcome_url: outcomeUrl } of run.files) {
if (outcomeUrl) {
const reportPath = name.replace(/\.json$/, '.report.json');
console.log(`Downloading the report of ${name}`);
const report = await fetch(outcomeUrl);
const reportBytes = await report.arrayBuffer();
await writeFile(reportPath, new Uint8Array(reportBytes));
console.log(`Saved ${reportPath}`);
}
}
}
try {
const fileNames = await uploadFiles(FILE_PATHS);
const runId = await startRun(fileNames);
const run = await pollRunStatus(runId);
await saveReports(run);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}