# Imports API

The CSV import, drivable end to end: upload the file, adjust the mapping, preview, commit, then poll for the report. It's the same import the [web wizard](/content/docs/usage/csv-import.html) runs — same mapping targets, same background job — so an agent's import and a human's can't behave differently.

```http
POST   /api/v1/imports              upload a CSV (multipart)
GET    /api/v1/imports              recent imports
GET    /api/v1/imports/:id          status, mapping, preview, report
PATCH  /api/v1/imports/:id          mapping, posture, tags, suppression
POST   /api/v1/imports/:id/commit   run it
```

Nothing is written to anyone until `commit`. Up to that point an import is a staged file plus decisions, all of them revisable.

## Upload

The one endpoint in the API that takes a file — multipart, under the field name `file`, and the first row must name the columns:

```bash
curl -X POST https://your-mimeo.com/api/v1/imports \
  -H "Authorization: Bearer mm_your_token" \
  -F "file=@subscribers.csv"
```

```json
{
  "import": {
    "id": 12,
    "status": "mapping",
    "filename": "subscribers.csv",
    "row_count": 4980,
    "mapping": { 
      "Email": "core:email", 
      "First Name": "core:first_name",
      "Company": "skip", 
      "Tags": "tag" 
    },
    "update_posture": "fill_blanks",
    "static_tags": [], 
    "suppress_all": false,
    "mark_historical": true
  },
  "headers": ["Email", "First Name", "Company", "Tags"],
  "sample_rows": [
    { "Email": "ada@example.com", "…": "…" }
  ],
  "mapping_targets": [
    "core:email",
    "core:first_name",
    "core:last_name",
    "core:subscribed_on",
    "field:company",
    "field:plan",
    "tag",
    "unsubscribed",
    "skip"
  ],
  "preview": {
    "sample": [
      { "email": "ada@example.com", "action": "update" }
    ],
    "existing_count": 14,
    "sample_size": 20
  }
}
```

The `mapping` that comes back is a guess from the header names, nothing more. `mapping_targets` is the full legal vocabulary, with this install's own custom fields spelled out as `field:<key>` — map against that list rather than guessing keys. A `field:` key that doesn't exist yet is created at commit, which is right when it's deliberate and quietly wrong when it's a typo of a key that does exist.

## Mapping and the decisions

Every column maps to one target:

| Target             | Behavior |
| -------------------| -------- |
| `core:email`      | The match key — lowercased, and an existing person with that address is updated, never duplicated. Exactly one column must carry this before commit. |
| `core:first_name` / `core:last_name` | The person's name fields. |
| `core:subscribed_on` | The date they joined the list. Only an **earlier** date replaces a stored one, so re-running an import is idempotent and batch order doesn't matter. An unreadable cell leaves the stored date alone instead of failing the row. |
| `field:<key>`     | A custom field. Multi-word values land as given. |
| `tag`             | The cell becomes tags, split on `|`, `,` and `;`. |
| `unsubscribed`    | A truthy value suppresses the person; a timestamp is preserved as the unsubscribe date. |
| `skip`            | Ignore the column. |

Send corrections — and the other three decisions — in one `PATCH`:

```bash
curl -X PATCH https://your-mimeo.com/api/v1/imports/12 \
  -H "Authorization: Bearer mm_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "mapping": { "Email": "core:email", "First Name": "core:first_name",
                 "Company": "field:company", "Tags": "tag" },
    "update_posture": "fill_blanks",
    "static_tags": ["from-mailchimp-2026-08"],
    "suppress_all": false,
    "mark_historical": true
  }'
```

- **`update_posture`** — how incoming values apply to people who already exist. `fill_blanks` (the default) writes only where Mimeo has nothing; `overwrite` makes the CSV the authority.

- **`static_tags`** — applied to every row. This is provenance: tag the batch and you can find, filter or walk it back later.

- **`suppress_all`** — the whole file arrives already unsubscribed. For suppression lists — import those _before_ the active list when migrating.

- **`mark_historical`** — defaults to `true`, and you almost always want it. Applying a tag emits a `tag_added` event, and events start flows, so a 20,000-row import would otherwise start 20,000 flow runs and mail all of them. With this on, the events the import emits are marked historical and no flow reacts to them — nothing has to be deactivated first. Set it to `false` only when you want the import to trigger automations on purpose, such as a small batch of genuinely new signups who should receive the welcome journey. See [backfilling without sending mail](/content/docs/api/events.html#historical/index.html).

Tags are additive either way — an import never strips tags a person already has — and an import never resubscribes anyone.

## Preview

Once an email column is mapped, every response carries a `preview` built from the sample rows: which would `update` an existing person, which would `create` a new one, and how many of the sampled addresses already exist. It's a sample, not a census — read it as "14 of the 20 sampled rows already exist", not as a promise about the other 4,960.

## Commit and poll

```bash
curl -X POST https://your-mimeo.com/api/v1/imports/12/commit \
  -H "Authorization: Bearer mm_your_token"
```

Commit refuses (`422`) while no column maps to `core:email`. It answers `202`, runs in the background, and from that point the import is read-only — further `PATCH`es and second commits refuse. Poll:

```http
GET /api/v1/imports/12

{ "import": {
    "id": 12,
    "status": "completed",
    "stats": { "created": 4211, "updated": 612, "skipped": 157, "processed": 4980 },
    "errors_sample": [ { "row": 88, "error": "invalid or missing email \"n/a\"" } ],
    "completed_at": "2026-08-01T17:03:11Z" } }
```

`status` moves `mapping` → `importing` → `completed` (or `failed`, with `error_message` saying why). `skipped` rows are the ones that couldn't be written — a missing or malformed email, almost always — and `errors_sample` names each one by line number so the source file can be fixed. Row errors are per-row, not fatal: the rest of the file imports around them.

A `failed` import stopped partway; rows already written stayed written. That's what the provenance tag in `static_tags` was for.
