# Importing contacts from CSV (/guides/importing-contacts)

When streaming contacts one by one through `/v1/track` isn't practical — migrating off another platform, seeding a fresh list, syncing a big batch — Bitelio lets you import them in bulk from a CSV file.

## CSV format

Your file needs a header row, and `email` is the only column that must be present. Any additional column is stored on the contact as a custom field at `data.<columnName>`.

A minimal file:

```csv
email
ada@example.com
grace@example.com
linus@example.com
```

A richer file with custom fields:

```csv
email,firstName,plan,signupDate
ada@example.com,Ada,pro,2026-01-15T00:00:00Z
grace@example.com,Grace,enterprise,2025-11-02T00:00:00Z
linus@example.com,Linus,free,2026-04-21T00:00:00Z
```

With this file, each imported contact gets `data.firstName`, `data.plan`, and `data.signupDate`.

### Rules and limits

* **File size**: 5 MB maximum per upload. Bigger lists should be split across several imports.
* **Encoding**: UTF-8. Files in other encodings risk mangled custom field values.
* **Email column**: required and validated. Any row whose email is absent or malformed comes back in the error report.
* **Reserved column names**: `id`, `subscribed`, `createdAt`, `updatedAt`, and the auto-generated URL variables (`unsubscribeUrl`, etc.) are dropped silently — leave them out of your file.
* **Date columns**: format them as ISO 8601 (`2026-05-06T12:00:00Z`) so Bitelio types them as dates, unlocking the `within` / `olderThan` segment operators.
* **Boolean columns**: values of `true`, `false`, `yes`, `no` (any casing) become booleans and show up as a boolean toggle in segment filters.
* **Numeric columns**: bare integers and decimals (`42`, `3.14`) become numbers, usable with `gt` / `lt` segment operators. Values with leading zeros (`01234`), a `+` prefix, or scientific notation are kept as strings — this protects IDs, zip codes, and phone numbers from corruption.
* **Existing contacts**: when a row's email already belongs to a contact, that contact is **updated** — the CSV's columns are merged into its `data`. No duplicate is created and the rest of the record is left intact.

## Importing your CSV

import {Tab, Tabs} from 'fumadocs-ui/components/tabs';

<Tabs items={['Dashboard', 'API']}>
  <Tab value="Dashboard">
    1. Go to **Contacts** and hit **Import**.
    2. Select the CSV. Bitelio checks the header and previews the first few rows for you.
    3. Confirm — the import is queued and processed in the background.
    4. Once done, the Imports page shows the outcome, including any row-level errors.
  </Tab>

  <Tab value="API">
    For automation, use the import API:

    **Step 1 — upload the CSV**

    ```bash
    curl -X POST https://bitelio-api.heltia.com/contacts/import \
      -H "Authorization: Bearer sk_..." \
      -F "file=@contacts.csv"
    ```

    The response includes a `jobId`:

    ```json
    { "jobId": "imp_abc123", "status": "queued" }
    ```

    **Step 2 — poll for status**

    ```bash
    curl https://bitelio-api.heltia.com/contacts/import/imp_abc123 \
      -H "Authorization: Bearer sk_..."
    ```

    Keep polling at a few-second interval until `status` reads `completed` or `failed`. The final response carries the counts:

    ```json
    {
      "jobId": "imp_abc123",
      "status": "completed",
      "totalRows": 12000,
      "imported": 11985,
      "updated": 8,
      "skipped": 0,
      "errors": [
        { "row": 47,   "email": "bad@",      "reason": "invalid_email" },
        { "row": 1042, "email": "@example",  "reason": "invalid_email" }
      ]
    }
    ```

    `imported` is the number of contacts created from scratch, while `updated` counts existing ones whose `data` was patched. Each entry in `errors` gives you the row number and the reason, so problem rows can be corrected and re-uploaded.
  </Tab>
</Tabs>

## Tips

* **Unsubscribed contacts**: every imported contact starts out subscribed. If the CSV contains people who never opted in, either set up a workflow that filters out anyone you shouldn't be emailing, or import first and then run `POST /contacts/bulk-unsubscribe` so they stay on record without being mailable.
* **Custom fields appear in segments immediately**: the moment the import completes, dynamic segments can filter on any of the imported columns.
* **Idempotent reruns**: uploading the same CSV again updates contacts in place instead of duplicating them — so after fixing a column, a re-upload pushes the corrected value to every matching row.

## Related

* [Custom fields](/guides/custom-fields) — how the `data.<column>` fields you import are typed and used.
* [Bulk operations](/api-reference/overview#contacts) — `bulk-subscribe`, `bulk-unsubscribe`, `bulk-delete` for mass actions on existing contacts (max 1,000 per call).
