Import contacts from CSV
Bulk-load your existing customer list into GetFluxly by uploading a CSV. Each row upserts a profile and writes trait events, so your dashboard has a populated audience before any SDK events arrive. Imported contacts can be used in segments, broadcasts, and automations from the dashboard.
Three-step upload flow
The import dialog walks you through three steps:
Step 1: Upload. Pick a UTF-8 encoded CSV file (max 10 MB, max 100,000 rows). Give the import a name. The name is stored as the imports trait on every contact in the batch, so you can filter by it later. If you leave the name blank it defaults to your filename minus the extension.
Step 2: Map columns. Assign each CSV column to a built-in profile field, a custom trait, or ignore it. The dialog auto-suggests mappings based on common header names.
Step 3: Confirm and attest. Review the row count and your mapping summary. Check the attestation box to confirm you have permission to contact these people. If you mapped marketing_opt_in, a second checkbox appears asking you to confirm that marketing consent was obtained on or before the dates in the file. Click Import to submit.
Column mapping
Every import must map exactly one column to email. Email is the contact identifier. If no column is mapped to email, the import is rejected before any rows are written.
Built-in fields
| Field | Notes |
|---|---|
email | Required. Normalized and used as the identity key. |
first_name | Stored as a reserved trait, not a profile column directly. |
last_name | Same as first_name. |
phone | Stored on the profile and added as a phone identity. |
marketing_opt_in | Boolean. Accepted values: true/false, yes/no, 1/0, t/f, y/n. Blank cells are treated as unset and do not overwrite existing values. |
transactional_opt_in | Boolean, same tokens as marketing_opt_in. |
marketing_consent_source | Required when marketing_opt_in is mapped. A string describing where consent was collected (e.g., signup-form-2025). |
marketing_consent_timestamp | Required when marketing_opt_in is mapped. An ISO 8601 timestamp for when the contact opted in. |
Marketing consent requirement
If marketing_opt_in is in your mapping, marketing_consent_source and marketing_consent_timestamp must also be mapped. The check is enforced both in the UI and at the API boundary. Rows where marketing_opt_in is true but the per-row consent source or timestamp is blank will fail with consent_source_required or consent_timestamp_required.
Custom traits
Any column not assigned to a built-in field can be mapped to a custom trait using the trait:<key> format. The key must be alphanumeric with underscores, dots, or dashes (^[a-zA-Z0-9_.-]+$). The trait key imports is reserved for import tracking and cannot be used as a custom trait key.
Columns set to Ignore are skipped entirely and never written.
Identity resolution
For each row the import resolves to an existing profile before deciding whether to insert or update:
- Look up
profile_identitiesfor(project_id, type='email', normalized_email). - If not found, fall back to
profiles.primary_identity = normalized_email(legacy path). - If still not found, insert a new profile with
primary_identityset to the email.
Profiles in a merge_state = 'merged' (tombstoned shells) are skipped. Writing into a merged shell creates a profile that no segment, campaign, or analytics query will ever read. Those rows are counted as failed.
Because email resolves to existing profiles, imported rows automatically stitch into each profile's prior anonymous activity. An anonymous session that later called identify with the same email will already have a profile in GetFluxly, and the import data lands on that unified profile. See Identity stitching for how the merge algorithm works.
Merge strategy
The update strategy is always merge. Only columns whose mapped value is non-null and non-blank overwrite existing profile values. Blank cells in the CSV are treated as "no value" and leave the existing profile field untouched. This means a CSV that has a phone column but an empty cell for a given row will not erase that contact's existing phone number.
Async processing
Once you click Import:
- The CSV is uploaded to storage and a
contact_importsrow is created withstatus = 'pending'. - The row's ID is pushed onto the
queue:contact_importsRedis queue. - The Go contact-import worker BLPOPs the queue and POSTs to an internal dispatch endpoint.
- The endpoint processes one batch of 500 rows, updates the progress counters, and returns
has_more. - While
has_moreistrue, the worker re-enqueues the job ID so concurrent imports from other projects can interleave fairly between batches. - The process repeats until all rows are processed or the import is cancelled.
Each successful batch resets the retry counter, so a long import burning through millions of rows does not exhaust the retry budget across batches.
Statuses
| Status | Meaning |
|---|---|
pending | Queued, not yet picked up by the worker. |
processing | At least one batch has started. The dashboard shows progress as a percentage of last_processed_row / total_rows. |
completed | All rows processed with zero failures. |
partial | Some rows processed successfully, some failed. |
failed | All processed rows failed, or the 10% bulk-fail threshold was exceeded. |
cancelled | A cancel request was received before the import finished. |
Bulk-fail threshold. If the failure rate crosses 10% of processed rows at any point during a batch, the import is aborted and marked failed with reason bulk_fail_threshold_exceeded. This surfaces errors such as the wrong column being mapped to email before the entire file is chewed through.
Progress and counts
The import row tracks three counters:
| Counter | Meaning |
|---|---|
succeeded_rows | Rows that resolved to or created a profile successfully. |
failed_rows | Rows that failed validation or the bulk-fail threshold check. |
skipped_rows | Rows that matched the global hard-bounce hash or the project's suppression list. Suppressed contacts are never written to the database. |
Progress displayed in the dashboard is last_processed_row / total_rows.
What gets written
For each successful row, the import writes:
profiles: upserted by email. Existing non-null columns are preserved (COALESCE merge strategy). Thefirst_identified_at,last_identified_at, andlast_profile_update_attimestamps are updated.profile_identities: anemailidentity is always upserted. Aphoneidentity is added when a phone value is present.profile_trait_events: one row per non-built-in column value (source =import), plus one row for the reservedimportstrait recording the import name. The trait-merge worker picks these up every five minutes and folds them intoprofile_traitsandprofiles.traits_jsonvia a COALESCE merge, so existing non-null trait values are not overwritten.
Retries and the dead-letter queue
The worker retries failed batches up to 3 times on transient errors (network issues, 5xx responses from the control plane). After 3 attempts the job is pushed to queue:contact_imports:dlq and the control plane marks the import failed. Permanent errors (not found, invalid mapping) skip retries entirely and go straight to the DLQ.
Error report
When an import reaches a terminal status (completed, partial, failed, or cancelled) and any rows failed or were skipped, a CSV error report is generated and stored. The report contains the original row data plus an _error_reason column.
Common error reason codes:
| Code | Cause |
|---|---|
missing_email | The email cell was blank after normalization. |
bad_email | The email value did not match a basic format check. |
bad_boolean | A marketing_opt_in or transactional_opt_in cell contained an unrecognized value. |
bad_consent_timestamp | A marketing_consent_timestamp cell was not a parseable ISO 8601 value. |
consent_source_required | Row opted in to marketing but marketing_consent_source was blank. |
consent_timestamp_required | Row opted in to marketing but marketing_consent_timestamp was blank. |
suppressed | Email matched the suppression list. The row is counted in skipped_rows, not failed_rows. |
merged_shell | The resolved profile was a tombstoned merge shell and could not be written to. |
Download the error report from the import history page using the Errors button. The button is visible only when a report exists. The download link is a short-lived signed URL.
Limits
| Limit | Value |
|---|---|
| Max file size | 10 MB |
| Max rows per file | 100,000 |
| Max imports per hour per project | 10 |
| Concurrent in-flight imports per project | 1 (a second submit returns HTTP 409) |
| Max cells per row | 500 |
| Max bytes per cell | 100 KB |
Only one import may be in pending or processing state at a time per project. To start a new import while one is running, cancel the active one first.