Skip to content
For developers
View as Markdown

Bulk import your data

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

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 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 page covers them in depth:

EntityWho belongs thereImport via
LeadsPeople in your pipeline you don't represent yet — inquiries, open-house sign-ins, referrals to follow upPOST /v1/leads
ClientsPeople you represent or have represented — active buyers and sellers, and your past-client basePOST /v1/clients
ContactsEveryone 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 is the current list of everything importable.

Step 3: Map your fields#

CRMs name the same fields differently. Common mappings:

Your old CRM's columnMaps to
First Name, FirstName, Given Namefirst name
Last Name, LastName, Surnamelast name
Email, Email Address, Primary Emailemail
Phone, Mobile, Cell, Primary Phonephone
Lead Source, Source, Originlead source
Notes, Comments, Descriptionnotes

The authoritative field names and which are required come from the generated reference — check clients and 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
  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 — copy that file into the same folder first. Then:

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"
// 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) 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:

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

Then open 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:

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