Contacts import

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

FieldNotes
emailRequired. Normalized and used as the identity key.
first_nameStored as a reserved trait, not a profile column directly.
last_nameSame as first_name.
phoneStored on the profile and added as a phone identity.
marketing_opt_inBoolean. 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_inBoolean, same tokens as marketing_opt_in.
marketing_consent_sourceRequired when marketing_opt_in is mapped. A string describing where consent was collected (e.g., signup-form-2025).
marketing_consent_timestampRequired 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:

  1. Look up profile_identities for (project_id, type='email', normalized_email).
  2. If not found, fall back to profiles.primary_identity = normalized_email (legacy path).
  3. If still not found, insert a new profile with primary_identity set 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:

  1. The CSV is uploaded to storage and a contact_imports row is created with status = 'pending'.
  2. The row's ID is pushed onto the queue:contact_imports Redis queue.
  3. The Go contact-import worker BLPOPs the queue and POSTs to an internal dispatch endpoint.
  4. The endpoint processes one batch of 500 rows, updates the progress counters, and returns has_more.
  5. While has_more is true, the worker re-enqueues the job ID so concurrent imports from other projects can interleave fairly between batches.
  6. 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

StatusMeaning
pendingQueued, not yet picked up by the worker.
processingAt least one batch has started. The dashboard shows progress as a percentage of last_processed_row / total_rows.
completedAll rows processed with zero failures.
partialSome rows processed successfully, some failed.
failedAll processed rows failed, or the 10% bulk-fail threshold was exceeded.
cancelledA 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:

CounterMeaning
succeeded_rowsRows that resolved to or created a profile successfully.
failed_rowsRows that failed validation or the bulk-fail threshold check.
skipped_rowsRows 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:

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:

CodeCause
missing_emailThe email cell was blank after normalization.
bad_emailThe email value did not match a basic format check.
bad_booleanA marketing_opt_in or transactional_opt_in cell contained an unrecognized value.
bad_consent_timestampA marketing_consent_timestamp cell was not a parseable ISO 8601 value.
consent_source_requiredRow opted in to marketing but marketing_consent_source was blank.
consent_timestamp_requiredRow opted in to marketing but marketing_consent_timestamp was blank.
suppressedEmail matched the suppression list. The row is counted in skipped_rows, not failed_rows.
merged_shellThe 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

LimitValue
Max file size10 MB
Max rows per file100,000
Max imports per hour per project10
Concurrent in-flight imports per project1 (a second submit returns HTTP 409)
Max cells per row500
Max bytes per cell100 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.