Importing Existing Users

Guide for third-party applications that already have users and need to import them into the Calimatic Auth platform.

This guide is for Category 4 apps: existing third-party applications that have their own user database and want to bring those users into the Calimatic Auth platform for centralized identity management.


Prerequisites

  • A registered app client with org:users:manage permission (see Integrate Third-Party)
  • An organization created in Calimatic Auth for your users
  • Access to your existing user database for data export
  • User data exported in JSON format

1. Prepare User Data

Required Fields

Each user record must include these fields:

FieldTypeRequiredMax LengthDescription
emailstringYes255Valid email address. Used as the unique identifier for deduplication.
firstNamestringYes100User's first name.
lastNamestringYes100User's last name.
organizationIdstring (UUID)No--Override the default organization for this user.
rolestringNo50Role within the organization (e.g., member, admin). Defaults to member.
temporaryPasswordstringNo128Min 8 characters. If set, user must change on first login.
passwordHashstringNo1024Existing bcrypt or PBKDF2 hash. Enables seamless migration -- users log in with existing password.
applicationsstring[]No--App licenses to assign. Defaults to the calling app's license.
externalIdstringNo255Your app's internal user ID. Stored for cross-referencing.
metadataobjectNo--Arbitrary key-value pairs for custom data.

Data Format

Structure your import payload as a JSON object:

{
  "users": [
    {
      "email": "jane@example.com",
      "firstName": "Jane",
      "lastName": "Smith",
      "role": "admin",
      "externalId": "usr_12345",
      "metadata": {
        "legacyPlan": "premium",
        "signupDate": "2023-06-15"
      }
    },
    {
      "email": "bob@example.com",
      "firstName": "Bob",
      "lastName": "Jones",
      "role": "member",
      "externalId": "usr_12346"
    }
  ],
  "defaultOrganizationId": "your-org-uuid",
  "defaultApplications": ["your-app-name"],
  "sendInviteEmails": false,
  "skipExisting": true
}

Data Cleaning Checklist

Before importing, clean your data:

  • Remove duplicates: Deduplicate by email address. The import API deduplicates for you (skipExisting: true), but cleaning first avoids unnecessary API calls.
  • Validate emails: Remove records with invalid or empty email addresses.
  • Normalize emails: Lowercase and trim all email addresses.
  • Trim names: Remove leading/trailing whitespace from names. Ensure no empty names.
  • Map roles: Convert your internal role identifiers to Calimatic role names.
  • Preserve IDs: Store your internal user IDs in the externalId field for cross-referencing.

Example: Transform CSV to Import Format

import fs from "fs";
import { parse } from "csv-parse/sync";

const csv = fs.readFileSync("users.csv", "utf-8");
const records = parse(csv, { columns: true, skip_empty_lines: true });

const ROLE_MAP: Record<string, string> = {
  "admin": "org_admin",
  "user": "member",
  "viewer": "member",
};

const importPayload = {
  users: records
    .filter((r: any) => r.email && r.email.includes("@"))
    .map((r: any) => ({
      email: r.email.toLowerCase().trim(),
      firstName: r.first_name?.trim() || "Unknown",
      lastName: r.last_name?.trim() || "User",
      role: ROLE_MAP[r.role] || "member",
      externalId: r.id,
      metadata: {
        legacyRole: r.role,
        createdAt: r.created_at,
      },
    })),
  defaultOrganizationId: "your-org-uuid",
  sendInviteEmails: false,
  skipExisting: true,
};

fs.writeFileSync("import-payload.json", JSON.stringify(importPayload, null, 2));
console.log(`Prepared ${importPayload.users.length} users for import.`);

2. Using the Import API

Endpoint

POST /api/v1/users/import

The Import API is an alias for bulk provisioning with migration-friendly defaults:

  • sendInviteEmails defaults to false
  • skipExisting defaults to true

Authentication

Authenticate with app client credentials or an API key:

# App client credentials
-H "x-client-id: cca_your_client_id"
-H "x-client-secret: ccas_your_client_secret"

# OR API key
-H "x-api-key: your-api-key"

Execute the Import

curl -X POST https://auth.calimatic.com/api/v1/users/import \
  -H "Content-Type: application/json" \
  -H "x-client-id: cca_your_client_id" \
  -H "x-client-secret: ccas_your_client_secret" \
  -d @import-payload.json
const response = await fetch("https://auth.calimatic.com/api/v1/users/import", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-client-id": process.env.CALIMATIC_CLIENT_ID!,
    "x-client-secret": process.env.CALIMATIC_CLIENT_SECRET!,
  },
  body: JSON.stringify(importPayload),
});

const result = await response.json();
console.log(result.data);

Response

{
  "success": true,
  "data": {
    "total": 150,
    "created": 142,
    "updated": 5,
    "skipped": 0,
    "failed": 3,
    "message": "Import complete: 142 created, 5 updated, 3 failed",
    "errors": [
      {
        "email": "bad-email",
        "error": "Must be a valid email address",
        "index": 23
      },
      {
        "email": "duplicate@example.com",
        "error": "Organization not found: invalid-uuid",
        "index": 87
      }
    ],
    "users": [
      {
        "email": "jane@example.com",
        "userId": "a1b2c3d4-...",
        "status": "user_created"
      },
      {
        "email": "existing@example.com",
        "userId": "e5f6g7h8-...",
        "status": "existing_user_updated"
      }
    ]
  }
}

Batch Large Imports

The API accepts up to 500 users per request. For larger datasets, batch your imports:

const BATCH_SIZE = 500;

async function importAllUsers(users: any[], orgId: string) {
  const allResults = { created: 0, updated: 0, failed: 0, errors: [] as any[] };

  for (let i = 0; i < users.length; i += BATCH_SIZE) {
    const batch = users.slice(i, i + BATCH_SIZE);
    const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
    const totalBatches = Math.ceil(users.length / BATCH_SIZE);

    console.log(`Batch ${batchNumber}/${totalBatches}: ${batch.length} users`);

    const response = await fetch("https://auth.calimatic.com/api/v1/users/import", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-client-id": process.env.CALIMATIC_CLIENT_ID!,
        "x-client-secret": process.env.CALIMATIC_CLIENT_SECRET!,
      },
      body: JSON.stringify({
        users: batch,
        defaultOrganizationId: orgId,
        sendInviteEmails: false,
        skipExisting: true,
      }),
    });

    const result = await response.json();
    allResults.created += result.data.created;
    allResults.updated += result.data.updated;
    allResults.failed += result.data.failed;
    allResults.errors.push(...result.data.errors);

    // Wait between batches to respect rate limits
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }

  return allResults;
}

3. Deduplication by Email

When skipExisting is true (the default for the import endpoint), the API deduplicates by email address:

  • If a user with the same email already exists in Calimatic Auth, the existing user is updated (added to the organization if not already a member) rather than creating a duplicate.
  • The existing user's keycloakUserId and profile are preserved.
  • App licenses are assigned if not already present.

Behavior Summary

ScenarioskipExisting: trueskipExisting: false
Email not foundCreate new userCreate new user
Email exists, different orgAdd org membershipAdd org membership
Email exists, same orgUpdate (no-op)Update (no-op)

This makes the import API idempotent -- you can safely re-run the same import without creating duplicates.


4. Handling Password Migration

Option A: Password Hash Migration (Recommended)

If your app uses bcrypt for password hashing, migrate the hashes directly. Users log in immediately with their existing passwords -- no reset email needed, no friction.

{
  "users": [
    {
      "email": "jane@example.com",
      "firstName": "Jane",
      "lastName": "Smith",
      "passwordHash": "$2b$12$LJ3m4ys3Lk0TSwHiPbUXR.ZaWgmFLfD5hsOqpDumNcBEkb/FumBcG",
      "externalId": "usr_12345"
    }
  ],
  "defaultOrganizationId": "your-org-uuid",
  "sendInviteEmails": false
}

Supported formats:

  • bcrypt ($2b$, $2a$) -- stored and verified as-is
  • PBKDF2 (Keycloak JSON format) -- automatically upgraded to bcrypt on first login

When passwordHash is provided, sendInviteEmail is automatically treated as false for that user.

Option B: Password Reset Emails

Import users without passwords, then send password reset emails. Users set their own password on the Calimatic Auth platform:

// Step 1: Import without passwords
const importResult = await importUsers(users, orgId);

// Step 2: Send password reset emails for successfully created users
for (const user of importResult.users) {
  if (user.status === "user_created") {
    await fetch(
      `https://auth.calimatic.com/api/v1/users/${user.userId}/reset-password`,
      {
        method: "POST",
        headers: {
          "x-client-id": process.env.CALIMATIC_CLIENT_ID!,
          "x-client-secret": process.env.CALIMATIC_CLIENT_SECRET!,
        },
      }
    );

    // Throttle to avoid overwhelming the email service
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
}

Option C: Temporary Passwords

Set a temporary password for each user during import. Useful when you want users to log in immediately but still force a password change:

{
  "users": [
    {
      "email": "jane@example.com",
      "firstName": "Jane",
      "lastName": "Smith",
      "temporaryPassword": "Welcome2024!"
    }
  ]
}

Users will be prompted to change the temporary password on first login.

You can also set a temporary password after import:

curl -X POST https://auth.calimatic.com/api/v1/users/{userId}/set-password \
  -H "Content-Type: application/json" \
  -H "x-client-id: cca_your_client_id" \
  -H "x-client-secret: ccas_your_client_secret" \
  -d '{ "temporaryPassword": "Welcome2024!" }'

Option D: SSO (No Passwords Needed)

If users already authenticate via Google Workspace, Microsoft 365, or another identity provider, configure SSO for the organization. Users sign in with their existing credentials -- no password migration is required.


5. Organization and Role Mapping

Single Organization

If all your users belong to one organization, use defaultOrganizationId:

{
  "users": [...],
  "defaultOrganizationId": "your-org-uuid"
}

Multiple Organizations

If users belong to different organizations, specify organizationId per user:

{
  "users": [
    {
      "email": "jane@acme.com",
      "firstName": "Jane",
      "lastName": "Smith",
      "organizationId": "acme-org-uuid",
      "role": "admin"
    },
    {
      "email": "bob@widgets.com",
      "firstName": "Bob",
      "lastName": "Jones",
      "organizationId": "widgets-org-uuid",
      "role": "member"
    }
  ]
}

Role Mapping

Map your internal roles to Calimatic role names. Common mappings:

Your RoleCalimatic RoleDescription
Super Adminorg_ownerFull organization control
Adminorg_adminUser and settings management
Managerorg_managerLimited admin capabilities
User / StaffmemberStandard member
Teacheredtech_teacherEdTech-specific teacher role
Studentedtech_studentEdTech-specific student role
Parentedtech_parentEdTech-specific parent role

If no role is specified, users default to member.


6. Verifying Imported Users

Check Individual Users

Use the resolve endpoint to verify a specific user was imported correctly:

curl "https://auth.calimatic.com/api/v1/users/resolve?email=jane@example.com" \
  -H "x-client-id: cca_your_client_id" \
  -H "x-client-secret: ccas_your_client_secret"

Response:

{
  "success": true,
  "data": {
    "user": {
      "id": "a1b2c3d4-...",
      "keycloakUserId": "kc-uuid-...",
      "email": "jane@example.com",
      "firstName": "Jane",
      "lastName": "Smith",
      "status": "active",
      "isActive": true,
      "source": "provisioning",
      "createdAt": "2025-01-15T10:30:00.000Z"
    },
    "organizations": [
      {
        "id": "org-uuid-...",
        "name": "Acme Corp",
        "membershipRole": "admin",
        "isPrimary": true
      }
    ],
    "licenses": [
      {
        "application": "your-app-name",
        "organizationId": "org-uuid-...",
        "assignedAt": "2025-01-15T10:30:00.000Z",
        "source": "provisioning"
      }
    ],
    "hasLicense": true
  }
}

Check Migration Status

For an overview of all imported users:

curl https://auth.calimatic.com/api/v1/tools/migration-status \
  -H "Cookie: your-session-cookie"

This returns counts by source (users with source: "provisioning" are your imported users), status, email verification rates, and login activity.

Verification Checklist

  • Total imported count matches expected count
  • No failed imports (check errors array in response)
  • Users are assigned to the correct organization
  • Roles are mapped correctly
  • App licenses are assigned (check hasLicense in resolve response)
  • externalId values are preserved for cross-referencing
  • At least one user can successfully log in

7. Setting Up Ongoing Sync via Webhooks

After the initial import, keep your app in sync with Calimatic Auth using webhooks.

Register a Webhook

curl -X POST https://auth.calimatic.com/api/v1/admin/webhooks \
  -H "Content-Type: application/json" \
  -H "x-client-id: cca_your_client_id" \
  -H "x-client-secret: ccas_your_client_secret" \
  -d '{
    "url": "https://myapp.com/webhooks/calimatic",
    "events": [
      "user.created",
      "user.updated",
      "user.deleted",
      "user.suspended",
      "user.deactivated",
      "user.reactivated",
      "license.assigned",
      "license.revoked"
    ],
    "secret": "whsec_your_secret_at_least_16_chars",
    "description": "Sync user lifecycle events to MyApp"
  }'

Handle Events in Your App

app.post("/webhooks/calimatic", async (req, res) => {
  // Verify signature (see Webhooks API reference)
  const signature = req.headers["x-webhook-signature"] as string;
  if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  const { event, data } = req.body;

  switch (event) {
    case "user.created":
      await createLocalUser(data.userId, data.email, data.firstName, data.lastName);
      break;
    case "user.updated":
      await updateLocalUser(data.userId, data);
      break;
    case "user.suspended":
    case "user.deactivated":
      await deactivateLocalUser(data.userId);
      break;
    case "user.reactivated":
      await reactivateLocalUser(data.userId);
      break;
    case "license.revoked":
      await revokeLocalAccess(data.userId, data.application);
      break;
  }

  res.status(200).send("OK");
});

Webhook Reliability

  • Webhooks retry up to 3 times with increasing delays (1s, 5s, 30s)
  • After 10 consecutive failures, the webhook is automatically disabled
  • Check delivery history via GET /api/v1/admin/webhooks/{id}/deliveries
  • Re-enable a disabled webhook via PATCH /api/v1/admin/webhooks/{id} with { "isActive": true }

For the full webhook API reference, see Webhooks API.


Related Documentation