TL;DR: Run a lead import the way you would run a database migration: rehearse with --dry-run, load a small batch, verify it, then scale. Idempotency keys make every step safe to repeat, and suppression check plus blocked domains keep the wrong people off the list.
#The angle: an import is a migration
A lead import looks harmless, which is why it goes wrong so often. You paste a file, press a button, and ten thousand records land in a place where an email system will later write to every one of them.
A database engineer would never treat that as a casual step. They would rehearse it, load a sample, check the result and only then load the rest, and they would make every step safe to run twice.
That is the angle of this guide: run your import like a migration. The FirstSales CLI gives you the pieces, and the habits come from you.
FirstSales is an AI SDR and cold email product. The commands below come from the @firstsales.io/cli README (version 0.1.11 as of October 2026), and nothing in this post was run against a live account while it was written.
Two request bodies are not spelled out in the README: contact-imports create and the list membership update. Where that matters, the post says so and points you to firstsales commands --json, --dry-run and the OpenAPI document at the OpenAPI document.
If you have not connected FirstSales to Claude Code, start with the five-minute MCP setup. The import itself is a CLI job, and MCP helps afterward with checking and list membership.
#What you need first
You need a working CLI session and a clean source file.
- The CLI installed with
npm i -g @firstsales.io/cli, published on npm. - A Developer API key with the narrowest scopes that cover the job, created as the authentication docs describe. The README's own example for
api-keys createuses scopes such ascontacts:write, and it notes that raw keys are shown once. - The key exported as
FIRSTSALES_API_KEYin your shell, never typed into a prompt and never saved in a file under version control. - Your organization and workspace identifiers, from
firstsales orgs listandfirstsales workspaces list --org <org>. - A source file with a known origin and a known purpose.
Confirm the session before anything else.
bashfirstsales whoami --json firstsales doctor --pretty
Read the organization and workspace in that output. A lead import into the wrong workspace is easy to make and tedious to reverse.
Keep one fact in mind about deletion. contacts delete and every other delete command cannot be undone, and the CLI requires --confirm for them. That is why this guide spends most of its length on prevention.
#The migration plan in five stages
| Stage | What you do | What you prove | Reversible? |
|---|---|---|---|
| 1. Clean | Fix the file locally | Rows are well-formed and deduplicated | ✓ |
| 2. Look | Read what the account holds | You know the starting state | ✓ |
| 3. Rehearse | Run with --dry-run | Request bodies are correct | ✓ |
| 4. Load small | Import 10 to 25 rows | Records land as expected | ✗ not cleanly |
| 5. Scale | Import the remainder in batches | Counts match the source | ✗ not cleanly |
The line between stage 3 and stage 4 is the one that matters. Before it, nothing has changed in the account. After it, records exist, and removing them means deletes you cannot undo.
flowchart LR A[Source file] --> B[Clean and dedupe locally] B --> C[Read current account state] C --> D[Dry run each request] D --> E{Bodies correct?} E -->|no| B E -->|yes| F[Load 10 to 25 rows] F --> G[Verify records and counts] G -->|mismatch| B G -->|match| H[Load remaining batches] H --> I[Final count check]
Every arrow that points backward goes to cleaning. If something is wrong, fix the source and start the stage again.
#Stage 1: clean the file before it touches FirstSales
The cheapest place to fix data is your own laptop. Nothing here needs an API call.
Start with a profile of the file. Ask Claude Code to produce one without sending anything anywhere.
Read leads.csv. Report row count, column names, empty email cells, duplicate emails (case-insensitive), and any email without an @. Do not call any FirstSales tool. Do not modify the file.
Then fix what it finds, in a copy. Keep the original untouched so you can always return to it.
The checks worth running locally:
- Lowercase and trim every email.
- Remove exact duplicates and report how many you removed.
- Drop rows with no email, or set them aside in a separate file.
- Check that names are not all caps, which often signals a bad export.
- Make sure the domain column, if you have one, matches the email domain.
Bad addresses cost more than they seem to. The guide on email verification before sending explains why a verification pass belongs ahead of any campaign, and catch-all email addresses covers the domains that accept everything.
Data also decays. The post on B2B data decay and list hygiene explains why last year's export is a risk, and waterfall enrichment describes how teams fill gaps without trusting one source.
Be honest about provenance. If you cannot say where a row came from and why that person would expect to hear from you, leave the row out. The legal side varies by country, and the guide on whether cold email is legal in 2026 is a good starting reference.
When the file is clean, write down the final row count. You will compare it with the account at the end.
#Stage 2: look at what the account already holds
Before you load anything, read what is already there. The README lists read commands for contacts, lists, tags and fields.
bashfirstsales contact-lists list --org org_123 --workspace ws_123 --json firstsales contact-tags list --org org_123 --workspace ws_123 --json firstsales contact-fields list --org org_123 --workspace ws_123 --json firstsales contacts list --org org_123 --workspace ws_123 --limit 20 --pretty
Replace org_123 and ws_123 with your own identifiers. The README documents --page, --limit and --all for list commands, so you can page through large results.
Save the outputs to a scratch folder. They are your before picture.
Look at contact-fields list with particular care. If your file has a column that does not map to a field the workspace knows, decide now what to do with it. Do not discover the mismatch at row nine thousand.
Check the existing lists too. Duplicated list names make later steps confusing, and a clear naming convention helps. A name such as 2026-10-dental-pilot says when, what and which batch.
If your workspace already contains contacts, expect overlaps with the new file. Decide what should win when a record exists: keep the old values, replace them, or merge them. The CLI has contacts merge and companies merge, but merging changes records, so plan it as its own migration.
#Stage 3: rehearse with a dry run
Every mutating CLI command accepts --dry-run, according to the README's global flags. The flag previews a request without sending it.
Rehearse with a single row first. The README shows this form for creating a contact.
bashfirstsales contacts create \ --org org_123 --workspace ws_123 \ --data '{"email":"[email protected]","firstName":"Alex"}' \ --idempotency-key import-row-001 \ --dry-run --json
Read what comes back. Check the method, the path, the body and the headers the CLI would send.
The idempotency key is the heart of a safe import. A key identifies one logical action, so if the command runs twice with the same key, the API can recognize a retry and avoid creating a duplicate. Choose keys that are stable per row, so a rerun after a crash reuses them.
A stable key can be derived from the data. Hash the lowercase email and prefix the batch name.
bashkey="pilot-001-$(printf '%s' "$email" | shasum | cut -c1-16)"
That line is a shell idiom, not a FirstSales feature. Keep the prefix tied to the batch so a second batch of the same people gets new keys on purpose.
Now rehearse the loop. The script below is an illustration for simple files, written for this post and not taken from the README. It reads a CSV with a header and three columns, and it prints each request without sending it.
bashtail -n +2 leads.clean.csv | while IFS=, read -r email first company; do body=$(jq -n --arg e "$email" --arg f "$first" '{email:$e, firstName:$f}') key="pilot-001-$(printf '%s' "$email" | shasum | cut -c1-16)" firstsales contacts create \ --org org_123 --workspace ws_123 \ --data "$body" --idempotency-key "$key" \ --dry-run --json done
Two cautions apply to this loop. First, read with a comma splits on every comma, so a field such as "Acme, Inc." breaks it. Use a real CSV parser for files with quoted fields. Second, the loop uses jq to build the JSON, which avoids the quoting bugs that come from string concatenation.
The README defines more fields on a contact than the two in its example, but it does not list them all. Run firstsales contact-fields list and read the OpenAPI document before you add columns to the body.
Count the dry-run lines. The number should equal your clean row count.
#Stage 4: load a small batch and verify
Now cut the file down to a small batch. Ten to twenty-five rows is enough to see problems and small enough to inspect by hand.
bashhead -n 11 leads.clean.csv > batch-001.csv
Run the same loop without --dry-run, and capture the output.
bashtail -n +2 batch-001.csv | while IFS=, read -r email first company; do body=$(jq -n --arg e "$email" --arg f "$first" '{email:$e, firstName:$f}') key="pilot-001-$(printf '%s' "$email" | shasum | cut -c1-16)" firstsales contacts create \ --org org_123 --workspace ws_123 \ --data "$body" --idempotency-key "$key" --json done | tee batch-001.out.jsonl
Keep the output file. It is your record of what the API said for each row.
The exit codes in the README help you build safe loops. Zero means success, 1 is a runtime error, 2 a usage error, 3 an auth error, 4 not found and 5 rate limited. A script that stops on the first non-zero code, and backs off on code 5, behaves far better than one that plows ahead.
Then verify, using a second view. Read the contacts back through the CLI, and compare them with the file.
bashfirstsales contacts list --org org_123 --workspace ws_123 --limit 25 --pretty
Ask Claude Code to compare the two, with the rule that it quotes record fields exactly.
Compare batch-001.csv with the contacts list output I saved. Report any email that is missing, any that appears twice, and any field that differs. Quote each record exactly. Do not call a write tool.
If you connected the MCP server, you can run a second check through it as well. The confirmed session had a contacts create tool and a list membership tool, so your tool list may also let you read companies. Treat MCP as a way to see the same account through another door.
What good looks like: counts match, no duplicates, names look right, and every record carries the fields you intended. If anything is off, fix the cleaning step and repeat the batch with new keys.
Do not move on until the small batch is boring. Boring is the goal.
#Stage 5: scale up
Once the small batch passes, load the rest in measured batches. A few hundred rows per batch is a sensible starting size, and the README documents that a company import accepts up to 10,000 rows in one request.
Why batch at all? A crash halfway through a ten-thousand-row loop is easier to recover from when you know exactly which batches finished. Name the batches, write the output of each to its own file, and log the counts.
Use the exit codes. If a batch returns code 5, wait and retry with the same idempotency keys, so rows that already succeeded are not created again.
After each batch, run the same verification as before. A good habit is a table in your notes.
| Batch | Rows in file | Rows created | Duplicates reported | Errors | Checked by |
|---|---|---|---|---|---|
| 001 | 10 | 10 | 0 | 0 | you ✓ |
| 002 | 250 | 249 | 1 | 0 | you ✓ |
| 003 | 250 | 250 | 0 | 0 | you ✓ |
This table is an invented example of a format, not real data. Your own numbers go in your own notes.
When all batches finish, compare the total created plus duplicates with the clean row count from stage 1. They should match. If they do not, the output files tell you which rows to investigate.
Keep the batch outputs for a while. They are a log of exactly what you asked the system to do.
#Importing companies in bulk
Companies have their own bulk command. The README documents companies import with a file body and an idempotency key.
bashfirstsales companies import \ --org org_123 --workspace ws_123 \ --data-file companies.json \ --idempotency-key pilot-001-companies
The body shape from the README is an object with a companies array, where each entry has a name and a domain. The README says the import accepts 1 to 10,000 rows, and the response reports created, duplicates and errors counts.
json{ "companies": [ { "name": "Acme Dental", "domain": "acmedental.example" }, { "name": "Northside Clinic", "domain": "northside.example" } ] }
The README also gives a one-liner that converts a CSV into this JSON with jq -R -s. Use it for simple files, and use a proper parser if your names contain commas or quotes.
Read the three counts after the call. A duplicates count above zero is normal when you import twice, and an errors count above zero deserves a look before you continue.
Run --dry-run on the company import too. Then import a small file first, as with contacts.
Companies matter because they carry the domain-level facts that outbound relies on. The post on buying signals for cold email explains why a company with a recent trigger is worth more than ten without, and cold email personalization at scale shows how company data feeds the copy.
#Building lists
Lists are how you tell a campaign who to write to. The CLI has contact-lists with list, create, update, delete and members.
Create a list with a clear name, and check its shape in a dry run first.
bashfirstsales contact-lists create \ --org org_123 --workspace ws_123 \ --data '{"name":"2026-10-dental-pilot"}' \ --idempotency-key pilot-001-list \ --dry-run --json
The body above is an assumption for the sake of illustration. The README does not document the exact body of contact-lists create, so confirm it with firstsales contact-lists create --help, the command reference, or the OpenAPI document before you run it for real.
Adding contacts to a list is the step the README leaves least specified. The CLI's command list includes contact-lists members, and the MCP side has a list membership update tool in the confirmed session. Neither has a documented request body in the README, so read the OpenAPI document and use --dry-run before you trust a guess.
There is also contact-imports create, which sounds like the natural command for a file import. Its body is not in the README either, so do not assume it matches the single-contact calls. Read the spec, then test it on a small file.
Whichever route you use, check the result. Ask for the list's members and compare the count with the batch.
Get the members of the list named 2026-10-dental-pilot. Report the count and the first ten emails. Compare with batch-001.csv.
Tags work alongside lists. The CLI has contact-tags list, rename and delete, so tag names are easy to inspect and, with care, to tidy. Renaming is reversible, and deleting a tag is not, so follow the same caution as other deletes.
#Keeping the wrong people out
A good import includes exclusions. Three tools in the CLI help.
Suppression check. The README documents suppression check for up to 100 values at once, and it shows the value, whether it is suppressed and the reason.
bashfirstsales suppression check [email protected] acme.com --org org_123 --workspace ws_123
Run it on a sample from your file, and on any address you worry about. Pass domains as well as emails, since the example does both. A suppressed value should not reach a campaign.
For a whole file, split your emails into groups of 100 and loop, then save the results.
Blocked domains. blocked-domains has list, add and remove. The README says a blocked domain also blocks its subdomains, that add needs an idempotency key, and that --campaign limits the block to one campaign.
bashfirstsales blocked-domains add \ --org org_123 --workspace ws_123 \ --data '{"domain":"competitor.example"}' \ --idempotency-key block-competitor-example --dry-run --json
As with lists, the body above is an illustration, so confirm it in the command reference before you run it. Block your own domain, current customers, partners and anyone who asked not to be contacted.
The MCP session confirmed tools for listing and adding blocked domains, which makes a good use of the conversation: ask the model which domains from your file appear in the blocked list, and have it show the matches.
Do-not-contact discipline. A suppression list only works if you keep it. Treat every opt-out as permanent. The guide on one-click unsubscribe in cold email explains how unsubscribes should behave, and the post on spam complaint rates shows what happens when they do not.
Google's email sender guidelines ask senders to keep spam rates below 0.10% and to avoid reaching 0.30%, as measured in Postmaster Tools. A clean list is the most direct way to stay under those lines.
#Where Claude Code helps and where it should not
Claude Code is useful at the edges of an import, and risky in the middle.
It helps with profiling, mapping columns, spotting odd rows and writing the loop. It also explains error output in plain language and keeps your batch log tidy.
It should not hold the key, decide which rows to import or run the unrehearsed command. The README gives agents a clear rule: inspect first, mutate deliberately, verify after every action.
Give the session standing rules in CLAUDE.md.
Lead import rules - Never print or ask for an API key. - Always run --dry-run first and show me the output. - Never import more than the batch I name. - Use the idempotency key pattern I give you. Never invent keys. - Never run a delete command. - After any import, compare counts with the source and report differences.
The rules are guidance and the permission prompt is the control. Keep prompts on for any command that writes. The guide to guardrails for email-sending agents explains why you need both, and MCP tool permissions for sales covers the access side.
A large import is also where a model's tendency to over-help shows up. It may offer to "fix" bad rows by guessing, such as inferring a missing first name from an email address. Decline. Guesses become copy, and copy becomes a message to a real person.
#Data quality checks that pay for themselves
A few cheap checks catch most import problems.
Row counts. Compare source, dry-run output, created count and the account count at every stage.
Domain sanity. List the ten most common domains in the file. If free mailbox domains dominate a B2B file, the source is suspect.
Role addresses. Addresses such as info@ and sales@ reach shared inboxes, and they tend to produce poor replies and more complaints.
Name hygiene. Look for names in all caps, names that are an email address, and placeholder values. Fix them locally, since personalization built on bad names looks careless.
Fit. Sample twenty rows and ask whether each belongs in your ideal customer profile. The post on defining an ideal customer profile helps, and segment validation tests show how to test a segment before you commit to it.
Measure the effect of cleaning, too. A list with 3% bad addresses and a list with 12% will behave very differently once sending starts. The numbers you see in cold email benchmarks assume a decently clean list.
#Rules of the road for the people in your file
An import is also a promise about how you will treat the people in it. Three rules keep that promise honest.
First, write to people for a reason you can explain. In the United States, the Federal Trade Commission's CAN-SPAM compliance guide lists the basics: honest headers and subject lines, a visible way to opt out, your physical postal address, and prompt handling of opt-out requests. Other regions add stricter consent rules, so check yours.
Second, make unsubscribing easy. RFC 8058 defines one-click unsubscribe for mail, and Google's sender guidelines require it for bulk senders. The import step matters here because an unsubscribe only works if the address stays suppressed after you re-import the same file next quarter.
Third, never re-add someone who opted out. Keep a suppression file outside the workspace, run suppression check against every new file, and block domains where the whole company asked to be left alone.
Write these three rules at the top of your batch log. The log then doubles as evidence of how you handled the data, which helps if a recipient or a regulator ever asks.
#A resume plan for interrupted imports
Terminals disconnect, laptops sleep and rate limits trip. A good import survives all three.
Keep one status file per batch with three columns: the key, the exit code and the time. The tee output from stage 4 already holds most of this. When a run stops, count the lines, find the last good key and restart from the next row.
Because every row carries a stable key, rerunning rows that already succeeded is harmless. The API can recognize them as retries. That property is why keys must be derived from the data and not from a counter or the clock.
If you see exit code 3, your key was rejected or has expired. Stop the loop, check firstsales whoami --json and fix the credential. Never respond by pasting a key into the chat to "refresh" the session.
If you see exit code 5, wait. A short sleep with growth between attempts is polite and effective. The sleep length is your call, since the README does not state the limits.
After a resume, run the count comparison again. The goal is the same as before: source rows equal created plus duplicates.
#Undoing a bad import
Prevention is cheaper than repair. If you do load something wrong, work out how bad it is before you act.
First, pause or stop anything that could send to the affected records. If a campaign already includes the list, pause it with firstsales campaigns pause before you do anything else.
Second, find the affected records. Your batch output files list the keys and results, so you can identify the exact rows.
Third, decide between fixing and removing. Updating a record with contacts update is reversible in spirit. Deleting is not.
The README requires --confirm for destructive commands, and it offers --dry-run for them as well. Dry-run the delete, read the exact target and then run it with --confirm.
bashfirstsales contacts delete --contact <contact-id> \ --org org_123 --workspace ws_123 --dry-run
The command above only previews, and only after you read the output should you add --confirm in a real run. A deleted contact cannot be restored, so do not let a model run delete commands in a loop.
Finally, record what happened. A short note on the cause saves the next import from the same error.
#A short checklist to print
Keep this list next to the terminal on import day.
firstsales whoami --jsonshows the organization and workspace you intend.- The source file has a copy, a clean version and a written row count.
- The before picture of lists, tags and fields is saved.
- Every request type has passed a
--dry-run. - Keys derive from the data and carry a batch prefix.
- The first batch holds no more than 25 rows and has been verified by eye.
- Suppression and blocked domains were checked against the file.
- No campaign includes the list until the import passes the count check.
- The final count matches the clean file.
- A note records what happened, what you changed and what to do differently.
The last item is the habit that compounds. A team that writes ten lines after every import stops repeating the same three mistakes.
#FAQs
#Can I import leads from a CSV with the FirstSales CLI?
Yes. Convert the rows to JSON and create contacts with firstsales contacts create, or use the bulk routes where the body is documented. Always rehearse with --dry-run.
#What is the safest way to run a big import?
Treat it as a migration. Clean locally, rehearse with --dry-run, load a small batch, verify, then scale in measured batches.
#What does --idempotency-key do?
It marks a request as one logical action. A rerun with the same key lets the API recognize a retry and avoid creating a duplicate.
#How do I make keys stable across reruns?
Derive them from the data, such as a batch prefix plus a hash of the lowercase email. Change the prefix when you want a deliberate new batch.
#How many companies can I import at once?
The README documents 1 to 10,000 rows per companies import request, with created, duplicates and errors counts in the response.
#What does the company import body look like?
An object with a companies array where each entry has a name and a domain.
#Where is the body for contact-imports create documented?
It is not in the README. Check the command reference, --help and the OpenAPI document before you use it.
#How do I add contacts to a list?
Use the list membership routes, but confirm the body first. The README does not document it, so read the OpenAPI spec and test with --dry-run.
#How do I check if an address is suppressed?
Run firstsales suppression check with up to 100 emails or domains. The output shows each value, whether it is suppressed and the reason.
#Does blocking a domain also block subdomains?
Yes, according to the README. You can scope a block to one campaign with --campaign.
#What do the exit codes mean?
Zero is success, 1 runtime error, 2 usage error, 3 auth error, 4 not found and 5 rate limited. Scripts should stop on the first failure and back off on 5.
#Can Claude Code run the import for me?
It can write and run the loop under your approval. Keep prompts on, keep the key in an environment variable and let it propose before it acts.
#Should I paste my API key into Claude Code?
No. Export it as FIRSTSALES_API_KEY, create it with the narrowest scopes, and never put it in a prompt, file or post.
#How do I verify an import?
Read the records back through the CLI, compare counts with your source, and check a sample by eye. MCP gives a second view if you have it connected.
#What if a batch partly fails?
Read the output file to find the failed rows. Fix the cause, then rerun with the same keys so successful rows are not duplicated.
#Can I undo an import?
Not cleanly. Deletes cannot be undone, so pause any campaign using the list and review the affected rows before you act.
#Does the CLI verify email addresses?
The README does not describe email verification for imports. Verify addresses with a separate step before you load them.
#Should I import role addresses like info@?
Usually not. They reach shared inboxes and tend to produce poor replies and more complaints.
#How big should the first batch be?
Ten to twenty-five rows. It is large enough to show patterns and small enough to inspect by hand.
#What should I do after the import?
Build the list, check exclusions, then follow the campaign build guide. Run readiness and a test email before you start.
#Conclusion
An import deserves the care you would give a migration. The tools are simple: --dry-run to rehearse, --idempotency-key to repeat safely, suppression check and blocked domains to keep people out, and exit codes to stop loops early.
Hold on to three rules. Rehearse before you load, load small before you load big, and verify with a second view. Never paste a key into a prompt, and never run a delete you cannot undo.
When the data is in, move on to the campaign itself with building a cold email campaign from Claude Code, then keep an eye on results using asking Claude how your campaign is doing.
Ready to try it? Run firstsales whoami --json, clean your file and rehearse the first row with --dry-run today.