# Bulk import your data

> Bring clients and leads in from your old CRM with a rate-limit-aware import script.

<!-- Source: https://docs.actuallycare.com/guides/bulk-import -->

Moving from another CRM? This guide walks you through exporting your data as CSV, mapping it onto ActuallyCare's entities, and importing it through the REST API with a script that respects rate limits and never creates duplicates.

**Honest scope note:** this is an API-based import — you'll run a small script from your terminal. If you'd rather have help migrating, or you want a hands-off import, reach out to support through [www.actuallycare.com](https://www.actuallycare.com) instead.

## Step 1: Export from your old CRM

Every mainstream CRM can export contacts as CSV. Look for **Export**, **Download**, or **Backup** in its settings, and export everything to one or more CSV files with a header row.

While you're in there, clean the data — it's far easier before import than after:

- Remove blank rows and obvious junk records
- Standardize phone formats
- Fix or remove invalid email addresses
- Merge duplicate rows (same person, two entries)

## Step 2: Decide what each record is

ActuallyCare distinguishes between three people-shaped entities — the [data model](/concepts/data-model) page covers them in depth:

| Entity | Who belongs there | Import via |
|---|---|---|
| **Leads** | People in your pipeline you don't represent yet — inquiries, open-house sign-ins, referrals to follow up | `POST /v1/leads` |
| **Clients** | People you represent or have represented — active buyers and sellers, and your past-client base | `POST /v1/clients` |
| **Contacts** | Everyone else in your sphere (vendors, other agents, lenders) | `POST /v1/contacts` — requires `first_name` and `contact_type` |

Most old-CRM exports mix all three together. The simplest approach: add a column to your CSV (or split it into two files) marking each row as a lead or a client. Past clients are clients — they're the relationships your follow-up automations care about.

Vendors, escrow officers, and other agents belong in **Contacts** (`POST /v1/contacts` with `first_name` and a `contact_type`). The documented surface also covers Escrows if you're bringing over open transactions — the [endpoint reference](/api/reference) is the current list of everything importable.

## Step 3: Map your fields

CRMs name the same fields differently. Common mappings:

| Your old CRM's column | Maps to |
|---|---|
| First Name, FirstName, Given Name | first name |
| Last Name, LastName, Surname | last name |
| Email, Email Address, Primary Email | email |
| Phone, Mobile, Cell, Primary Phone | phone |
| Lead Source, Source, Origin | lead source |
| Notes, Comments, Description | notes |

The authoritative field names and which are required come from the generated reference — check [clients](/api/reference/clients) and [leads](/api/reference/leads) before you write your mapping, and adjust the `mapRow` function in Step 4 to match. Don't guess — check the reference for the exact spelling of every field you map. One difference that bites people: clients take `first_name` and `last_name`, but **leads take a single `name` field** — the `mapRow` function in Step 4 handles both.

If your data has a source or tag-like field, stamp every imported row with a marker such as `import-2026-06`. Months later, being able to find exactly what this import created (or archive it, if something went wrong) is worth a lot.

## Step 4: The import script

The script below:

1. Reads your CSV
2. Fetches your **existing** records first and indexes them by email, so re-running the script never creates duplicates
3. Creates one record every 2 seconds — about 450 requests per 15 minutes, safely under the [500-per-15-minutes rate limit](/api/rate-limits)
4. Logs failures to a file so you can fix and re-run just those rows

It reuses the `actuallycare.js` API client module from the [custom integration tutorial](/guides/build-custom-integration) — copy that file into the same folder first. Then:

```bash
mkdir crm-import && cd crm-import
npm init -y
npm install csv-parse
# copy actuallycare.js (from the custom integration tutorial) into this folder
export ACTUALLYCARE_API_KEY="your-64-character-key"
```

```javascript
// import.js
// Usage: node import.js <file.csv> <clients|leads> [--limit N]
const fs = require('fs');
const { parse } = require('csv-parse/sync');
const api = require('./actuallycare');

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const PACE_MS = 2000; // 1 request per 2s ≈ 450 per 15 min, under the 500 limit

// Adjust to your CSV's column names and the fields documented at
// /api/reference/clients and /api/reference/leads.
// The shapes differ: clients take first_name/last_name,
// while leads take a single name field.
function mapRow(row, resource) {
  const email = row.email?.toLowerCase().trim() || undefined;
  const phone = row.phone?.trim() || undefined;

  if (resource === 'leads') {
    const name = [row.first_name, row.last_name]
      .map((s) => s?.trim())
      .filter(Boolean)
      .join(' ');
    return { name, email, phone };
  }

  return {
    first_name: row.first_name?.trim(),
    last_name: row.last_name?.trim(),
    email,
    phone,
  };
}

// Page through existing records once and index by email,
// so we can dedupe before creating anything.
// List responses put records at data.<resource> with pagination at data.meta.
async function fetchExistingEmails(resource) {
  const emails = new Set();
  const limit = 100;
  for (let page = 1; ; page++) {
    const data = await api.get(`/${resource}`, { page, limit });
    for (const record of data[resource]) {
      if (record.email) emails.add(record.email.toLowerCase().trim());
    }
    if (!data.meta.hasMore) break;
  }
  return emails;
}

async function run() {
  const args = process.argv.slice(2);
  const [csvPath, resource] = args;
  const limitIdx = args.indexOf('--limit');
  const maxRows = limitIdx === -1 ? Infinity : Number(args[limitIdx + 1]);

  if (!csvPath || !['clients', 'leads'].includes(resource)) {
    console.error('Usage: node import.js <file.csv> <clients|leads> [--limit N]');
    process.exit(1);
  }

  const rows = parse(fs.readFileSync(csvPath), {
    columns: true,
    skip_empty_lines: true,
  }).slice(0, maxRows);

  console.log(`Checking existing ${resource} for duplicates...`);
  const existing = await fetchExistingEmails(resource);
  console.log(`${existing.size} existing emails indexed. Importing ${rows.length} rows...`);

  const results = { created: 0, skipped: 0, failed: [] };

  for (const [i, row] of rows.entries()) {
    const record = mapRow(row, resource);

    if (record.email && existing.has(record.email)) {
      results.skipped++;
      console.log(`[${i + 1}/${rows.length}] skip (exists): ${record.email}`);
      continue;
    }

    try {
      await api.post(`/${resource}`, record);
      results.created++;
      if (record.email) existing.add(record.email);
      console.log(`[${i + 1}/${rows.length}] created: ${record.email || record.name || record.last_name}`);
    } catch (err) {
      results.failed.push({ row, code: err.code, message: err.message });
      console.error(`[${i + 1}/${rows.length}] FAILED: ${err.code} - ${err.message}`);
    }

    await sleep(PACE_MS); // stay under the rate limit
  }

  console.log(`\nDone. Created ${results.created}, skipped ${results.skipped}, failed ${results.failed.length}.`);
  if (results.failed.length > 0) {
    fs.writeFileSync('failed-rows.json', JSON.stringify(results.failed, null, 2));
    console.log('Failed rows written to failed-rows.json — fix and re-run.');
  }
}

run().catch((err) => {
  console.error('Import aborted:', err);
  process.exit(1);
});
```

A few design notes:

- **Dedupe before create.** The API doesn't know your old CRM's record IDs, so email is the stable key. Skipping existing emails makes the script safe to re-run after a crash or a partial import.
- **Pacing matters.** At one create per 2 seconds, a 1,000-row import takes about 35 minutes. That's intentional — bursting through the rate limit gets you `429` responses ([rate limits](/api/rate-limits)) and a forced wait anyway. Let it run in the background.
- **Failures are data.** A failed row usually means a missing required field or a malformed value. The `error.code` in `failed-rows.json` tells you which.

## Step 5: Dry run with 5 rows

Never run the whole file first. Import five rows:

```bash
node import.js my-export.csv clients --limit 5
```

Then open [app.actuallycare.com](https://app.actuallycare.com) and check those five records in the Clients section. Verify:

- Names landed in the right fields (not reversed, no stray whitespace)
- Emails and phones look right
- Your source/tag marker is present, if you mapped one

If something's off, fix `mapRow`, archive the test records in the app, and dry-run again. Iterating on 5 rows costs seconds; discovering a mapping bug after 2,000 rows costs an afternoon.

## Step 6: Run the full import

Once the dry run looks right:

```bash
node import.js my-export.csv clients
node import.js my-leads.csv leads
```

The script prints progress per row and a summary at the end. If any rows fail, fix the data in `failed-rows.json` (or in your CSV), then re-run — the dedupe check means already-imported rows are skipped, so re-running is harmless.

## Step 7: Verify in the app

When the script finishes, confirm the migration in the app, not just in the terminal:

1. **Counts match.** The Clients and Leads sections should show roughly your old CRM's totals (minus skipped duplicates and held-aside rows). The script's `created` plus `skipped` numbers should account for every row.
2. **Spot-check 10 random records.** Open a few clients and leads you know well — right name, right email, right phone.
3. **Search works.** Search for a couple of names and emails; if search finds them, the fields landed where the app expects them.

## Import checklist

**Before**

- Export and clean your CSV (blanks, duplicates, bad emails)
- Decide lead vs client for every row
- Check field names against [the reference](/api/reference)
- Create an API key with the scopes the import needs — for example `{"clients":["read","write"],"leads":["read","write"]}`; a key with no scopes gets `403` on everything — and set `ACTUALLYCARE_API_KEY`

**During**

- Dry-run 5 rows and verify in the app before the full run
- Watch the log for repeated failures — stop and fix rather than letting hundreds of rows fail the same way
- Keep the pacing — don't "speed it up" past the rate limit

**After**

- Verify counts, spot-check records, test search
- Re-run failed rows from `failed-rows.json`
- Keep your original CSV export until you're confident — it's your backup
