Theme

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 runs — same mapping targets, same background job — so an agent's import and a human's can't behave differently.

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:

curl -X POST https://your-mimeo.com/api/v1/imports \
  -H "Authorization: Bearer mm_your_token" \
  -F "[email protected]"
{
  "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": "[email protected]", "…": "…" } ],
  "mapping_targets": ["core:email", "core:first_name", "core:last_name",
                      "core:subscribed_on", "field:company", "field:plan",
                      "tag", "unsubscribed", "skip"],
  "preview": { "sample": [ { "email": "[email protected]", "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:

TargetBehavior
core:emailThe 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_nameThe person's name fields.
core:subscribed_onThe 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.
tagThe cell becomes tags, split on |, , and ;.
unsubscribedA truthy value suppresses the person; a timestamp is preserved as the unsubscribe date.
skipIgnore the column.

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

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
  }'

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

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 PATCHes and second commits refuse. Poll:

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 mappingimportingcompleted (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.

See also: CSV import, for humans · Events API · the manager repo, whose import-a-csv skill drives these endpoints