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:
| 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 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 | |
| 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 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:
- Reads your CSV
- Fetches your existing records first and indexes them by email, so re-running the script never creates duplicates
- Creates one record every 2 seconds — about 450 requests per 15 minutes, safely under the 500-per-15-minutes rate limit
- 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
429responses (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.codeinfailed-rows.jsontells 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 5Then 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 leadsThe 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:
- 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
createdplusskippednumbers should account for every row. - Spot-check 10 random records. Open a few clients and leads you know well — right name, right email, right phone.
- 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 gets403on everything — and setACTUALLYCARE_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