Skip to content

Set up bulk user import

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.

How it works

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

Object storageMosaicYouObject storageMosaicYouRequest upload URLsPresigned URL and form fieldsUpload JSON fileStart import runRead fileValidate records and create usersPoll run progressTotals and outcome file URLs
Object storageMosaicYouObject storageMosaicYouRequest upload URLsPresigned URL and form fieldsUpload JSON fileStart import runRead fileValidate records and create usersPoll run progressTotals and outcome file URLs

The import flow:

  1. You request upload URLs from Mosaic (POST /v1/migration/users/files/upload-urls). See Request upload URLs.
  2. You upload each JSON file directly to object storage using its URL. See Upload each file.
  3. 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.
  4. 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.
  5. 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.

Before you start

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.

Step 1: Create a management app

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.

  1. In the Admin Portal, go to Settings > Management Apps.
  2. Click + Add management app, give it a name (for example, Bulk user import), and save.
  3. 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_SECRET

Step 2: Align the user schema

Before you transform the export, make sure Mosaic can store the attributes you plan to import.

  1. 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.
  2. Review the default Mosaic user schema.
  3. 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.

Step 3: Prepare the JSON file

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_id can be included, but it does not count as an identifier. Records with only external_user_id fail validation and aren't created.
  • Identifiers must be unique across all records in the file and across existing Mosaic users (username is 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:

FieldDescription
user_idOptional 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_verifiedWhether the primary email is already verified.
email_verified_atWhen the email was verified (ISO 8601).
phone_verifiedWhether the primary phone number is already verified.
phone_verified_atWhen the phone number was verified (ISO 8601).
statusUser status: Active, Disabled, or Pending.
last_authTimestamp of the user's last authentication in the source system (ISO 8601).
hashed_passwordPre-hashed password to store as-is. Mosaic verifies it at login using the given algorithm. Do not send a plaintext password.
hashed_password_historyArray 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:

FieldDescription
hashThe password hash string.
algorithmAlgorithm used to produce the hash. Supported values: argon2, bcrypt, sha, sha1, sha2, sha256, sha512, ssha, ssha256, ssha512, md5, smd5, firebaseScrypt, drupalPhpass.
saltSalt used when hashing, if the algorithm requires it.
force_replaceWhen 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
      }
    }
  }
]

Step 4: Upload the files

Files are uploaded directly to object storage, not through the Mosaic API host. The upload takes two calls per batch of files:

  1. Request an upload URL for each file name from Mosaic.
  2. 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.

Request upload URLs

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

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.

Step 5: Start the import

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 reports created as 0.
  • 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_run or live.
  • app_id: the application the imported users will be assigned to.
  • files: the uploaded file names to process. If you omit files, 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.

Step 6: Track progress and review failures

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"
      }
    ]
  }
}
  • status for the run is running, completed, failed, or cancelled. completed_at is present only after the run ends.
  • processed, created, and failed are totals across every file. failed covers 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 duplicated user_id, email, username, or other unique identifier).
  • files reports the same counters per uploaded file. Each file status is pending, running, completed, or failed.
  • outcome_url is 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.

Step 7: Cancel a run

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'

Upload file management

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.

Sample migration script

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:

  1. Set values for APP_ID and BASE_URL at the top of the script. APP_ID is the application imported users will be assigned to, not the management app.
  2. 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;
}