Skip to main content
BeginnerGetting Started

Importing Contacts

How to import contacts into NimbusOS from CSV, connected CRMs, or Apollo, and how the import pipeline deduplicates, enriches, suppresses, and scores each row before it becomes a live contact.

10 min read
Updated April 23, 2026
2,100 words

The contact import flow is the entry point for nearly every record in NimbusOS. Whether the rows come from a CSV, a HubSpot sync, an Apollo export, or a webhook from your CRM, they go through the same ImportJob pipeline. That pipeline is responsible for seven things in strict order: parsing, field mapping, dedup against GlobalContact, enrichment (optional), suppression check, ICP segmentation, and lead scoring.

This article walks through the flow from the user side and covers the questions that usually trip up a first import: which columns matter, how dedup actually works, how to decide whether to enrich at import time or later, and what to do when a row fails.

Supported Sources

NimbusOS can import contacts from four source types.

CSV upload. The most common path. Any CSV under 500 MB. Headers are required. Comma, tab, semicolon, and pipe delimiters are all auto detected. Quoted fields with embedded delimiters work. Encoding is auto detected; UTF-8 without BOM is the safest choice.

Direct CRM sync. If you have connected HubSpot, Salesforce, or Outreach through the Integrations page, you can select a saved view or a list as the import source. The sync pulls contacts in pages, creates an ImportJob like any other import, and keeps a watermark so future syncs only pull changed records.

Apollo search. If you have connected an Apollo key, you can run a people search from the Prospects page and push the result into an ImportJob. The Apollo adapter translates your saved IdealCustomerProfile into an Apollo /v1/mixed_people/search query. Apollo credits burn from your account; NimbusOS does not charge for the lookup.

Webhook intake. For continuous pipelines (like the Apify LinkedIn Jobs Scraper), the webhook endpoint at /api/webhooks/intake/ accepts batches of contacts. Each batch creates its own ImportJob so you can audit the intake.

The Column Map That Matters Most

The first time you run an import, NimbusOS asks you to map the CSV columns to Contact fields. The mapping is saved as a FieldMapping record so subsequent imports from the same source skip this step.

There are three fields that control the quality of everything downstream. Map them carefully.

Email is the primary key. NimbusOS lowercases, trims, and canonicalizes it before any database write. A row with a missing or invalid email is rejected during parse and never reaches dedup.

Company domain is the secondary key. It drives the Company record match, enrichment cache lookup, and the GlobalContact company context. If your source gives you a company name but not a domain, NimbusOS will run a resolution attempt, but the match rate is lower. Always prefer a domain column.

Full name or first name plus last name. The personalization engine uses first name as {{first_name}} in templates. If only a full name is present, NimbusOS splits on the first space. Custom suffixes like "Jr." or "PhD" are detected and moved into a separate field.

The rest of the columns (title, department, seniority, phone, LinkedIn URL, location) are optional but valuable. Seniority and department drive ICP segmentation. Location drives send time optimization.

If your CSV has a column the standard fields do not cover, create a custom field for your workspace and map to it. Custom fields are queryable from segments and templates.

Deduplication: Why You Cannot Double Book a Prospect

Dedup in NimbusOS runs against GlobalContact, which is a platform wide table keyed on email. This is different from most outbound tools, which dedup only within the single account.

When a row hits the dedup step, three outcomes are possible.

New contact. The email does not match any existing GlobalContact. A new GlobalContact and a new Contact are created. If agency mode is on, a ClientAssignment record pins the contact to the importing sub client with a priority and an expiry.

Updated contact. The email matches an existing GlobalContact already assigned to the importing client. The existing Contact row is updated with any new fields from the import. Existing values are preserved if the import field is blank. Conflicts (two different company domains for the same email) are logged on the ImportJobRow.

Skipped contact. The email matches an existing GlobalContact already assigned to a different client with an unexpired ClientAssignment. The row is skipped. This is the rule that prevents two agencies from outreach collisions on the same human.

The dedup logic also surfaces engagement history. If the email has been sent to before, the GlobalContact carries counters: total_sent, total_responses, response_rate, bounce_status. These are visible in the Contact detail page so you know if you are about to burn a second attempt on a prospect who never responded the first time.

Enrichment at Import Time (or Not)

The enrichment step is optional. Turning it on adds cost and time. Turning it off leaves the contact with only the columns you imported. Choose per import.

Enrich at import when the CSV is slim (email + name + company only) and you plan to launch a sequence the same day. The EnrichmentJob fires one provider call per contact, writes to EnrichmentCache keyed on email hash, and populates industry, employee count, tech stack, and LinkedIn URL on the Company and Contact. Expect roughly 80 percent coverage from a single provider, which is why the cache matters: once enriched, a re import does not recharge credits.

Skip enrichment at import when the CSV already has the columns you need, when you want to ship a send window without waiting for enrichment, or when you plan to enrich only the contacts that survive scoring. The last case is the efficient one for large lists: import, score, segment down to the tier A and B rows, then enrich only those.

BYOK applies here. If you have added an Apollo, Clay, or Clearbit API key to your workspace (EnrichmentProviderKey with Fernet encryption), credits burn from your account, not a shared pool. This is the standard path for agencies running outreach for multiple sub clients.

Suppression Check

Every import row passes through the suppression list before insert. Suppression is enforced in three layers.

Workspace level. Your SuppressionList table blocks emails you have marked as do not contact. Reasons include opt out, hard bounce, spam complaint, unsubscribe, manual, and compliance.

Domain level. SuppressionDomain blocks entire domains. Useful for suppressing competitor domains, current customer domains, and any industry you have agreed not to outreach.

Platform level. Emails that have globally opted out appear with is_global=True. These are honored across every workspace on the platform.

A suppressed row is not imported. The ImportJobRow is marked with status=suppressed and the reason is recorded. You can review suppressed rows in the import report.

ICP Segmentation and Scoring

After dedup and suppression, each surviving row is assigned an icp_tier (A, B, C, or D) and a lead_score (integer 0 to 100).

The icp_tier is rule driven. You configure ICPTierRule objects that evaluate title, company size, industry, tech stack, and geography. The first rule that matches wins. Rules can be tuned per workspace, so agency sub clients can use different tier definitions.

The lead_score is computed by a ScoringModel with three weighted components: fit, engagement, and intent. At import time only fit is known, so the score starts low and will climb as engagement accrues. The letter grade (A, B, C, D, F) is derived from the score against configurable thresholds.

Both tier and score drive sequence eligibility and dashboard sorting. The NowListEntry daily ranking surface pulls from these fields.

Monitoring the Import

Every import creates a row at /imports with a live status. The stages are visible in order: queued, parsing, deduplicating, enriching, suppressing, segmenting, scoring, complete.

Under the hood the ImportJob is running a Celery chain. If a stage fails (for example, enrichment quota exhausted), the job pauses at that stage with a clear error message. You can resume from the failed stage without re-uploading.

At completion you get a summary: total rows, imported, updated, skipped, errored. The errored rows are exportable as a CSV with the original row plus the error reason, so you can fix and re-upload.

Common Problems and Fixes

Everything is skipped. Your CSV email column is not mapped, or the emails are malformed. Re-open the field mapping. Reject rate over 20 percent usually means the source file has a different column order than expected.

Import is stuck on enriching. The enrichment provider has hit a rate limit. NimbusOS backs off and retries, but for very large lists consider disabling enrichment at import and running it as a separate job afterward.

Company match rate is low. Your company domain column is name, not domain. Add a transform step, or re-export from the source with a domain field, or accept the lower match rate.

Some contacts show "client assigned to X" on import. These are rows matched to a GlobalContact owned by another client on the platform. They are not imported to your workspace. If you believe the assignment is stale, the original owner has to release it or let the assignment expire.

Frequently Asked Questions

Is there a row limit per CSV?

Soft limit 500,000 rows per upload. Above that, split into smaller files. The dedup step is O(n) with respect to the import size and roughly O(log n) against GlobalContact, so very large single uploads stretch the job duration without breaking anything.

Can I re-import the same CSV to refresh enrichment?

Yes. Duplicates will hit the "updated contact" path and any new fields in the CSV will write. Enrichment will not re-run if the cache is fresh. To force a re-enrich, delete the cache entry from Data Enrichment or set force_refresh=true on the import.

How does import interact with segments?

Dynamic Segment membership is evaluated on read, so a new contact shows up in any segment whose rules it satisfies as soon as the import completes. Static segments need a manual add step.

Where do custom fields live?

Custom fields are workspace scoped. They appear on the Contact model as a JSON property. You reference them in segments, templates, and the scoring engine by name.

Is there a way to dry run an import without writing rows?

Yes. Toggle "Preview mode" on the import form. NimbusOS will run the first 100 rows through parse, dedup, and scoring, and show you the outcome without writing to the database. This is the fastest way to validate a field mapping on a new source.

After your first import is live, the most useful next pages are Data Enrichment for how providers, caching, and BYOK work together, Segments and Filters for building the ICP A and B cut that feeds your first campaign, and Deduplication for the full story on GlobalContact and ClientAssignment.

Related articles

Still stuck?

Our team answers every support ticket. If the answer is not in the docs, open a ticket and we will write the missing page.