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:
| 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:
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;overwritemakes 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 totrue, and you almost always want it. Applying a tag emits atag_addedevent, 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 tofalseonly 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.
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 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.
See also: CSV import, for humans · Events API · the manager repo, whose import-a-csv skill drives these endpoints