# CSV Import

Import contacts in bulk from a CSV file, with tags, custom fields, consent records and opt-out statuses.

CSV import lets you add hundreds or thousands of contacts to SendBeam in a single operation.
Whether you are moving an audience from another email platform, uploading a list from your
CRM, or bringing in attendees from an event, the importer handles the heavy lifting — including
tags, custom fields, the original opt-in dates, proof of consent and the people who have
already unsubscribed.

The import is simple: prepare a CSV with the right column headers, upload it, and review the
result. Each stage is described below.

## Preparing your CSV file

- The file must be in **CSV format** (`.csv` extension), **UTF-8 encoded**. Comma, semicolon and tab delimiters are detected from the header row.
- The first row must be a **header row** with column names.
- The file needs a column holding the **email address**. Header names are
  matched case-insensitively, with spaces and punctuation treated as underscores, so
  `Email`, `First Name` and `Opt-In Time` all work. Using the
  names below means the mapping screen is right first time; anything else you correct there
  in a click.
- Quoted cells may contain commas, quotes (doubled) and line breaks.
- The file may be at most **5 MB** — roughly 50,000 rows. Larger uploads are refused; split the CSV and import it in parts.

> Download the **example file** from the import page to see exactly the
> format SendBeam expects. It's the fastest way to get started without formatting errors.
> 

## Recognised columns

| Column | What it does |
| --- | --- |
| `email` | **Required.** Up to 254 characters. `email_address` is accepted as an alias. |
| `first_name`, `last_name` | Optional, up to 100 characters each; used by the `{"{{first_name}}"}` and `{"{{last_name}}"}` merge tags. |
| `status` | `subscribed`, `unsubscribed`, `bounced`, `complained` or `pending`. Aliases: `active` → subscribed, `cleaned` → bounced, `cancelled` / `canceled` → unsubscribed, `junk` / `spam` → complained, `unconfirmed` → pending. A blank cell (or no column) takes the **default status** chosen on the import page. Rows marked `pending` are **never imported** — the person has not confirmed — and are counted as *pending rows*. Any other value makes the row invalid. |
| `tags` | Tag names separated by `;` or `\|` (for example `vip;beta`). Up to 20 per row, names up to 60 characters. Tags are matched to existing tags case-insensitively and created when missing (at most 200 distinct new names per import). |
| `subscribed_at` | The original opt-in time, kept as the contact's subscription date. Aliases: `opted_in_at`, `signup_at`, `optin_time`, `created_at`. Accepted formats: ISO 8601 (`2024-01-05T10:00:00Z`), `YYYY-MM-DD HH:MM:SS`, `YYYY-MM-DD`, `DD/MM/YYYY`, and Unix timestamps. Values without a time zone are treated as UTC. A date in the future is clamped to now; an unreadable value is treated as blank, which means "now". |
| `unsubscribed_at` | When the person opted out (aliases `opted_out_at`, `unsub_time`). Used for unsubscribed, bounced and complained rows; when absent, the row's `subscribed_at` or the import time is used. |
| `source` | A per-row source label (up to 40 characters) that overrides the source set on the import page. Rows with a blank cell take the page's value (default `import`). |
| `language` | The language the person reads in, as a two-letter code (`fr`, `de`; `fr-FR` and `pt_BR` are reduced to the code). Aliases `lang`, `locale`, `language_code`, `preferred_language`. A value that is not a language is left blank rather than failing the row. On an existing contact it fills an empty language; it replaces one only when the import is set to update existing contacts. |
| `consent_ip`, `consent_at`, `consent_source` | Proof of consent from your previous platform: the IP address the person signed up from, when they confirmed (aliases `confirm_time`, `opt_in_confirmed_at`; any of the date formats above), and the form or notice they saw. These are stored as the custom fields `consent_ip`, `consent_confirmed_at` (normalised to ISO 8601) and `consent_source`, so they travel with the contact and appear in exports. |
| *any other column* | Becomes a **custom field** keyed by the lower-cased, snake_cased header (`Company Name` → `company_name`), available as `{"{{custom_fields.company_name}}"}` and in segment rules. Values are stored as text, cut to 200 characters; an empty cell stores nothing. A contact may hold at most 50 custom fields, so columns beyond that, columns whose key would be empty or repeated, and headers over 64 characters are ignored and listed under *columns ignored* in the result. |

### Sample file

```
email,first_name,last_name,status,tags,subscribed_at,source,consent_ip,consent_at,consent_source,company
alice@example.com,Alice,Smith,subscribed,vip;beta,2024-01-05 10:00:00,footer-form,203.0.113.9,2024-01-05 10:02:11,Footer newsletter form,Acme Ltd
bob@example.com,Bob,Jones,subscribed,,2023-11-20,,,,,
carol@example.com,Carol,,unsubscribed,,2022-03-01,,,,,
dave@example.com,Dave,,cleaned,,2021-07-14,,,,,
erin@example.com,Erin,,pending,,2026-08-30,,,,,
```

This file creates Alice (tagged *vip* and *beta*, with her consent record and a
`company` custom field), Bob, Carol as unsubscribed and Dave as bounced — both added
to the suppression list with their original dates — and leaves Erin out because she never
confirmed.

A row whose email is missing, malformed or too long, whose name exceeds the limit, or whose
status is not one of the values above, is counted as **invalid** and not imported.
If the same address appears more than once in the file the rows are merged: the strongest
status wins (complained over bounced over unsubscribed over subscribed) and tags and fields are
combined.

## Statuses and the suppression list

Rows with status `unsubscribed`, `bounced` or `complained`
are imported as contacts with that status *and* recorded on the workspace's
[suppression list](https://sendbeam.io/docs/contacts/suppressions). That is how you carry
your opt-outs across: campaigns never reach them, and a later import can never bring them back
as subscribed. They are a compliance record rather than audience, so they do
**not** count towards your plan's contact limit — only rows that will be
*subscribed* do.

Addresses already on the suppression list — people who previously unsubscribed, bounced,
complained or were deleted in this workspace — are imported as **unsubscribed**
whatever the file says. They can only return by subscribing again through a signup form.

## Uploading the file

1. Go to **Contacts** in the sidebar, then click **Import** near the
  top of the page.
2. Drop your CSV file onto the upload area, or click it to browse for the file.
3. **Check the columns.** Every column in the file is listed with a few sample values
  and what it will become: a contact field (matched from the header — `Primary email`,
  a single `Name` column and a `Surname` are understood too), one of your
  existing custom fields (matched by key or label), or *Ignore*. A column SendBeam does not
  recognise is ignored until you map it, so a `MEMBER_RATING` or an internal id never
  becomes a field by accident. Choose **New custom field…** to keep a column as a
  field of its own, with a key and a **type** — a Birthday column becomes a Date
  field, a seats column a Number — and it is declared under [Settings → Custom fields](https://sendbeam.io/docs/contacts/custom-fields)
  as part of the import. Values that do not fit the type are left out of that row and reported;
  nothing is bent to fit. Exactly one column must be mapped to Email, and a field can be fed by
  one column only.
4. Choose the **default status** for rows without a status — *Subscribed* or
  *Unsubscribed*. Use Unsubscribed for a suppression list or an audience that has not
  opted in.
5. Optionally set a **source** label (default `import`) for rows that do
  not carry their own, so you can find this batch later in segments.
6. Optionally enter **tags for every contact** (separated by `;`) —
  handy for marking a migration batch — and pick a **list** to add the subscribed
  contacts to. On a double opt-in list (and every list in a Free-plan workspace) they are added
  as *awaiting confirmation*; the import never sends confirmation emails, so send them
  yourself from the list page if you need to re-confirm an audience.
7. Leave **Skip duplicate emails** ticked to keep existing contacts' names, or
  untick it to refresh their names and source from the file (see Handling duplicates below).
8. Leave **Run automations for these contacts** unticked unless you mean it. An
  import brings people in; it does not greet them. Tick it and every contact the file creates
  enters your **Contact created** automations, and every contact it adds to a
  list enters that list's **Joined a list** automations — which is almost never
  what you want when you are moving an existing list in, since those people were welcomed
  long ago somewhere else. The summary tells you how many enrolments it started.
9. Click **Import**. SendBeam checks the account's remaining contact allowance
  before it reads the file, reads only as many subscribed rows as the plan has room for, then
  creates the contacts in batches. Through the API (no mapping screen) the headers are read as
  described above and every unrecognised column becomes a custom field; send a
  `mapping` to get the same control as the screen — see the API reference.
10. The result appears on the same page when the import finishes. Keep the page open until it
  does.

> Any active automation with the **Contact Created** trigger enrols every newly
> created contact whose status is Subscribed, so pause a welcome sequence first if you do not
> want it to fire for an existing audience — or import with the default status set to
> Unsubscribed. A **List Joined** automation fires for contacts added to a
> single opt-in list; on a double opt-in list it waits until they confirm.
> 

> **Plan limit.** If the number of *subscribed* rows in the file is more
> than the contact slots left on your plan, the whole import is refused with a message telling
> you how many slots remain — even if some rows would have been skipped as duplicates. Rows
> with an opt-out status never count. Split the file or upgrade under
> [Billing & Plans](https://sendbeam.io/docs/admin/billing).
> 

## Handling duplicates

When an email address in your CSV already exists in the workspace, the
**Skip duplicate emails** option decides what happens to the contact's
*names and source*:

- **Ticked (recommended)** — the existing contact's first name, last name and
  source are left unchanged and the row is counted as a skipped duplicate.
- **Unticked** — the existing contact's first name, last name and source are
  refreshed from the file. Use this when your CSV carries fresher names.

Either way, the file's data is **added** to existing contacts: its tags are
attached, its custom fields are merged in (keys the file carries take the file's value; other
keys are kept), and a currently subscribed contact whose row says
`unsubscribed`, `bounced` or `complained` is moved to that
status and suppressed — an opt-out in the file is always honoured. The reverse never happens:
an import cannot turn an unsubscribed contact back into a subscribed one, and a complaint is
never downgraded to a plain unsubscribe. Contacts changed this way are counted as
*updated*. Re-importing a file is therefore safe to repeat.

## Migrating from another platform

Export your audience from the old platform including the unsubscribed and cleaned members and
their signup dates, rename the headers to the names above where they differ (most exports call
the address column *Email Address* and the opt-in date *OPTIN_TIME* or
*CONFIRM_TIME*), and import the whole file in one go. Keeping the original
`subscribed_at` and consent columns matters: they are your evidence that each person
opted in, and the opt-out rows keep you from ever emailing someone who left. If you would rather
we did the move with you, the [Move-in guide](https://sendbeam.io/docs/getting-started/moving-in)
walks through the whole process — export, domain setup, import and the first send.

## Import from Mailchimp, MailerLite, Kit, Brevo or EmailOctopus

If your audience is on Mailchimp, MailerLite, Kit, Brevo or EmailOctopus you can skip the
export altogether. Go to
**Contacts → Import → Import from another platform**, paste an API key from the
old platform and click **Connect**. SendBeam checks the key with a single call
and shows the account, its audiences / groups / tags / lists with subscriber counts, and
exactly what will and will not come across. Pick what to bring over, optionally a tag for the
batch and a SendBeam list, and click **Import**.

### Where to create the key

- **Mailchimp** — profile icon → *Account & billing* → *Extras* → *API keys* → *Create A Key*. The key ends in a data-centre suffix such as `-us21`; SendBeam reads the data centre from it.
- **MailerLite** (the new MailerLite, not Classic) — *Integrations* → *MailerLite API* → *Generate new token*.
- **Kit** — *Settings* → *Developer* → *API Keys*; use the v4 key.
- **Brevo** — in your account settings, under *SMTP & API* → *API Keys* → *Generate a new API key*.
- **EmailOctopus** — under *Account* → *API*, create a new API key.

None of the five platforms issues a truly read-only key, so create one just for the import
and delete it on the old platform once you are done. SendBeam only ever reads with it: the key
is held in your browser tab for the visit, sent with each request, and never stored, logged or
displayed again. Nothing on the old platform is changed.

### What is imported

- **Contacts** with email, first and last name and their status. Mailchimp `cleaned` → bounced; MailerLite `junk` → complained; Kit `cancelled` → unsubscribed. Archived and transactional Mailchimp members are skipped.
- **Unsubscribed, bounced and complained** addresses are recorded on the [suppression list](https://sendbeam.io/docs/contacts/suppressions) *before* any contact is written and are imported as contacts with that status. They never count towards your contact limit.
- **Unconfirmed / pending** people (Mailchimp `pending`, MailerLite `unconfirmed`, Kit `inactive`, EmailOctopus `pending`) are counted but never imported — they did not confirm.
- **Original opt-in date** as `subscribed_at`, and — where the platform exposes it — the **consent evidence**: IP address, confirmation time and signup source, stored as the custom fields `consent_ip`, `consent_confirmed_at` and `consent_source`. Mailchimp and MailerLite provide these; Kit provides the signup source (form, landing page, API, referrer, with UTM values as custom fields) but no IP or confirmation time; Brevo's consent IP is only known for people who later unsubscribed; EmailOctopus provides neither IP nor confirmation time, only the signup source on a double opt-in list. A Mailchimp confirmation time is only carried for double opt-in audiences; on single opt-in audiences the opt time is kept as `mailchimp_timestamp_opt`. MailerLite's `unsubscribed_at` is carried as the opt-out time.
- **Tags**: Mailchimp tags, MailerLite groups, Kit tags, Brevo lists and EmailOctopus tags become SendBeam tags (created when missing). Kit tags arrive with each subscriber, so any number of tags is mapped in one pass. Mailchimp audience group interests become `interest:` tags, and members with more than 50 tags have their full set fetched separately (up to 40 such members per run; the rest carry the custom field `mailchimp_tags_truncated` and the result reports *tags truncated*).
- **Custom fields**: merge fields, MailerLite fields, Kit fields, Brevo attributes and EmailOctopus fields become custom fields keyed by their snake_cased name (up to 30 per contact, values cut to 200 characters; address-type fields are dropped). Mailchimp GDPR marketing permissions become `mc_permission_` fields.

### What is not

- Campaign history, templates, automations, forms, landing pages, segments and statistics stay on the old platform.
- Mailchimp does not expose spam complaints separately (they are folded into `cleaned`); Kit exposes no consent IP or confirmation timestamp; paid Kit newsletters are not carried over; EmailOctopus does not expose bounces or complaints as contact statuses at all (they only appear in campaign reports), so those addresses arrive as subscribed or unsubscribed.
- Nothing is deleted or modified on the old platform, and no email is sent to anyone by the import.

### Caps and re-running

A run reads the source one page at a time and writes each page as it goes, so a large audience
never sits in memory. Each run handles up to **25,000 contacts** (rounded up to the
end of the source page in progress), makes at most 250 requests to the old platform and runs
for at most about 45 seconds. When a cap is hit — or your plan has no room for the next page of
subscribed contacts — the run stops *between* pages, reports everything written so far
with `truncated: true`, and offers **Continue import**, which resumes
from exactly that page. Re-running is always safe: contacts already in the workspace are
skipped as duplicates, tags and custom fields are added, an opt-out in the source always wins
over a subscribed contact here, and a stronger suppression reason is never downgraded. The
same audience can be imported again later to pick up new signups.

The two calls behind the page are available to your own code with an API key that has
`contacts:write` (Pro or Business): `POST /api/v1/imports/connect` with
`{"source", "credentials": {"api_key"}}`, then
`POST /api/v1/imports/run` with the same plus `list_ids` and
`options` (`tags`, `list_id`, `max_contacts`,
`cursor` from a previous run's `next_hint`). See the
[API reference](https://sendbeam.io/docs/api#post-api-v1-imports-run).

## Addresses that cannot receive mail

Two kinds of address are skipped before a row becomes a contact. The first needs no lookup:
**placeholders** that can never belong to a subscriber — anything on
`example.com`, `example.net` or `example.org` (or a subdomain of
them), anything on a reserved `.test`, `.invalid`, `.localhost` or
`.example` domain, and the `abuse@` and `postmaster@` mailboxes
that every domain runs for its mail administrators. Sample rows left in an export and addresses
scraped from a contact page are the usual sources. Each one is skipped whatever
`check_domains` says, and the summary names them (the first twenty) with the reason,
so you can fix the file rather than wonder why a row is missing. A campaign's Review step and
its page make the same check on the audience and list any that are already contacts.

Every import also checks the domain of each remaining address. A domain that
does not exist, that publishes a "null MX" (an explicit *no mail here*), or that has neither
mail servers nor an address of its own cannot be delivered to, so those rows are skipped and the
summary lists the domains with the most rows lost — typos like `gmial.com` and
companies that have closed are the usual culprits. Only a definite DNS answer skips a row; a slow
or failing lookup lets the row through. Well-known providers are never looked up, and an import
with more than 500 distinct domains checks the first 500.

This is a domain check, not a mailbox check: a real domain with a mistyped local part still
imports and bounces later, at which point the contact is marked *bounced* and suppressed
as usual. Pass `check_domains=false` on the API to skip the check.

## Import results summary

Once your import finishes, SendBeam displays a summary so you can verify everything went as
expected:

- **Contacts imported** — new contacts created.
- **Existing contacts updated** — contacts that already existed and received custom fields, a refreshed name or an opt-out status from the file.
- **Duplicates skipped** — rows whose email address already existed in the workspace.
- **Added to the suppression list** — unsubscribed, bounced and complained addresses recorded so they are never mailed.
- **Pending rows not imported** — rows with status `pending`.
- **Invalid rows** — rows dropped because the email was missing or malformed, a value exceeded its limit, or the status was unrecognised.
- **Tags attached** (and new tags created) — contact–tag links made by this import.
- **Custom fields** — the keys the file mapped, and **columns ignored** — headers that could not become custom fields.
- **Added to the list** — confirmed memberships, or memberships awaiting confirmation on a double opt-in list.

The same import is available to your own code: `POST /api/v1/contacts/import` with
a multipart body containing `file` and the optional `default_status`,
`source`, `skip_duplicates`, `run_automations`
(`false` unless you send `true`), `tags` and
`list_id` fields, authenticated with an API key that has `contacts:write` (Pro or Business).
The response carries `imported`, `updated`,
`skipped_duplicates`, `suppressed`, `skipped_unconfirmed`,
`invalid`, `skipped_undeliverable`, `undeliverable_domains`,
`skipped_placeholder`, `placeholder_addresses`,
`tags_created`, `tags_attached`,
`custom_field_keys`, `dropped_columns`, `list_added`,
`list_pending_confirmation` and `truncated`; see the
[API reference](https://sendbeam.io/docs/api#post-api-v1-contacts-import) for every field. A
file over 5 MB is refused with `413`.

If none of the rows are usable, the import fails with a message asking for an
`email` column header and at least one data row. SendBeam does not keep an import
history, so note the summary if you need it for your records.

---
Source: https://sendbeam.io/docs/contacts/importing
