Partner API

Contact Lists v1

Bulk-load a contact list into a Meteoric organization from a CSV you host in your own S3 bucket. You presign the object; we fetch it once, copy it, and import it through the same pipeline the Meteoric UI uses — dedupe, DSAR suppression, opt-out suppression, and optional district enrichment all apply.

  • Base URLhttps://api.getmeteoric.io/api/partner/v1
  • AuthAuthorization: Bearer mtk_live_…
  • Content typeapplication/json

§0

Read this before you build the CSV

Start here

Quickstart

Five steps from nothing to an imported universe. Each one links to the section that explains it properly.

  1. Mint a key

    In the Meteoric app, go to Settings → API keys and create a key with the Contact list import preset. The token is shown once. See Authentication.

  2. Prove which organization it writes into

    Call GET /whoami. It imports nothing and tells you the organization, user, role, scopes and limits the key carries.

  3. Presign the CSV for GET

    At least 15 minutes, an hour recommended, signed for GET, on the bucket’s own S3 host. Send the string your SDK produced, byte for byte. See Presigning the object.

  4. POST the job

    POST /contact-lists with an idempotency_key and the presigned URL, plus van_ids: true and id_state or id_state_column if the file has VAN ids (see The VAN id state). You get 202 Accepted and a status_url back — nothing has been fetched yet.

  5. Poll to completion

    GET /contact-lists/{id} every 5 seconds until status is succeeded or failed. There is no webhook. Then read the row counts and, if anything failed, the failed-rows CSV.

§1

Authentication

Every request carries a Personal Access Token as a bearer token:

Text
Authorization: Bearer mtk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
  • Tokens are prefixed mtk_live_. Every key acts on your organization’s live data — verify a new key with /whoami before importing anything.
  • The token carries its organization. There is no X-Org-Id header to set — if you send one and it disagrees with the token’s organization the request is refused with org_mismatch. One token writes into exactly one organization; if you push to two organizations you hold two tokens.
  • Tokens are shown once, at creation. Store it in your secret manager.
  • Every authentication failure returns the same 401 unauthorized with the same message, deliberately — absent, malformed, revoked and expired are indistinguishable from the outside.

Where keys come from

Keys are self-serve. A Meteoric admin enables API access for your organization, and then anyone whose role holds the api_keys permission mints keys at app.getmeteoric.io → Settings → API keys. The full walkthrough — presets, expiry, limits, revocation — is on the API keys section of the overview.

Verify a key without importing anything

Shell
curl -sS https://api.getmeteoric.io/api/partner/v1/whoami \
  -H "Authorization: Bearer $METEORIC_TOKEN"

§2

Endpoints

Five operations, all under /api/partner/v1.

MethodPathPurposeThe key needs
POST/contact-listsCreate a bulk-load job from a presigned S3 CSV URLcontacts: import
GET/contact-lists/{id}Status of one jobcontacts: view, import or create (any one)
GET/contact-listsRecent jobs, paged (reconciliation)contacts: view, import or create (any one)
GET/field-schemaMachine-readable field vocabularyany key
GET/whoamiWhich organization/user this key acts asany key

The key’s scopes and its user’s role must both allow the request (see GET /whoami). Since 2026-10-06 the two list reads refuse a key with no contacts scope (before, any key of the organization could read them). A key that imports lists can always read them.

Rate limits

EndpointLimit
POST /contact-lists10 / minute, 100 / day
GET /contact-lists/{id}120 / minute
GET /contact-lists30 / minute
GET /field-schema, GET /whoami60 / minute each

Exceeding a limit returns 429 with error code rate_limited and a Retry-After header. Separately, an organization runs at most 3 imports at a time; further imports are accepted and queued, first-in first-out, up to 10 waiting. Beyond that the request is refused with 409 import_queue_full and a Retry-After header. We fetch your file immediately either way — only the import waits.

§3

POST /contact-lists

POST/api/partner/v1/contact-lists10 / minute · 100 / day

Create a bulk-load job from a presigned S3 CSV URL. Returns immediately; the fetch and import happen in the background.

Request

FieldRequiredNotes
idempotency_keyRequired8–64 chars of A–Z a–z 0–9 . _ : -. See Idempotency.
source.typeOptionalOnly s3_presigned_url today.
source.urlRequiredA presigned GET URL. See Presigning the object.
list_nameOptionalDisplay name in Meteoric. Defaults to the S3 object’s filename.
outreach_idOptionalAttach the imported contacts to this outreach (a campaign in the app). See Attaching to a campaign.
field_mappingsOptionalOmit to let us resolve headers automatically (see CSV contract).
matching_keyOptionalexternal_id (default), email, or custom:<name>: one of your organization’s custom fields, for an id of your own (since 2026-10-08). See matching_key.
van_idsOptionaltrue is required when the file has VAN ids. Confirms that the column on external_id (yours, or one we map from a header such as vanid, voter_id or just id) holds VAN ids. Without it such a file is refused (van_ids_unconfirmed). See The VAN id state.
id_stateOptionalOne of the two is required when the file has VAN ids. One state for every VAN id in the file. See The VAN id state.
id_state_columnOptionalOne of the two is required when the file has VAN ids.The header of the column holding each row’s VAN id state. See The VAN id state.
confirm_id_state_mismatchOptionaltrue to go ahead when id_state differs from enrichment.district_state. See The VAN id state.
merge_strategyOptionaladd_new_only (default), update_all, skip_existing.
enrichment.*OptionalDefaults as shown in the example.
enrichment.district_stateOptionalThe state your file’s bare congressional-district numbers belong to, as a code (NV) or name. VAN exports write NV-02 as 002; with this set it is stored as NV-02. It applies to every row, even one whose statecolumn names another state. Omit it and the target outreach’s district is used, else each row’s own state, else its ZIP. A bare number with no state from any of these is left blank, never stored as 002.

Unknown keys are rejected (422 invalid_request) rather than ignored, so a typo can never silently change import behavior.

JSONRequest body
{
  "idempotency_key": "moveon-ga-2026-09-03-01",
  "source": {
    "type": "s3_presigned_url",
    "url": "https://moveon-lists.s3.us-east-1.amazonaws.com/universes/ga-2026-09.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=20260903T170000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=..."
  },
  "list_name": "GA Universe 2026-09",
  "outreach_id": 4821,
  "field_mappings": {
    "vanid": {"field": "external_id"},
    "first": {"field": "first_name"},
    "last": {"field": "last_name"},
    "cell": {"field": "phone_1"},
    "zip": {"field": "zip_code"},
    "support": {"field": "custom:support_level"},
    "notes": {"field": "skip"}
  },
  "matching_key": "external_id",
  "merge_strategy": "add_new_only",
  "van_ids": true,
  "id_state": "GA",
  "enrichment": {
    "enabled": false,
    "include_congressional": true,
    "include_state_leg": true,
    "zip_district_fallback": false
  }
}

The VAN id state (id_state / id_state_column)

Since 2026-10-05. A VAN id names a voter only within a state. When your file has a VAN id column — any column mapped to external_id, whether you sent field_mappings or we resolved the headers (Automatic header resolution) — send "van_ids": true to say it holds VAN ids (since 2026-10-07), and tell us which state the ids belong to, with exactly one of:

FieldUse it whenValue
id_stateEvery row is a voter in the same state.A 2-letter code or the name: the 50 states, DC, and the territories PR, GU, VI, AS, MP (any case; "North Carolina", "nc" and "NC" are the same).
id_state_columnThe file mixes states.The exact header of the column that holds each row’s VAN id state. Its cells take the same values as id_state.
  • Say they are VAN ids (van_ids: true, since 2026-10-07). A column on external_id is always read as VAN ids, including one we map for you from a header named just id (unless it holds row numbers: we skip those, see Automatic header resolution). Without van_ids: true the request is refused with 400 van_ids_unconfirmed, before the state is asked; when we map the header ourselves, the job fails with van_ids_unconfirmed once we have read it, before any row is imported. Both name the column. If it is not VAN ids (your own person id), keep it in a custom: field and, if it is your stable id for each person, send that field as matching_key (Matching on your own id). van_ids: true on a file with no such column is ignored.
  • Exactly one. Neither (with a VAN id column) is id_state_required; both is id_state_ambiguous. A file with no VAN id column needs neither, and either field is ignored for it.
  • Organizations whose VAN ids are unique nationwide (an organization setting, off by default, set in Meteoric under Settings → VAN IDs) need neither, nor van_ids: their ids match on themselves alone. A request that sends id_state or id_state_column anyway is accepted, the value is ignored (not checked), and the 202 says so in warnings with the code id_state_ignored.
  • Your address is not used for it. state, zip_code and congressional_district describe the address, which is often a mailing address in another state. If your statecolumn really is each voter’s registration state, name it in id_state_column; we never assume it.
  • A blank or unrecognised cell in id_state_column (an empty cell, Ontario, a ZIP): that row is not imported — not matched by its id, not created. It counts in rows.failed and is in the failed-rows file with the reason (_category no_id_state). Rows without a VAN id are imported as usual.
  • A congressional district in another state.A district is voting geography: when a row’s congressional_district cell names its own state (GA-05, ga 5, Georgia 5 — not a bare 005) and that state is not the row’s VAN id state, the row is not imported either (rows.failed, _category id_state_conflict). Address state and ZIP are not compared.
  • If every row with a VAN id fails one of these two ways, the job ends failed: id_state_missing when any had a blank or unrecognised cell, else id_state_conflict.
  • An id someone else already holds in that state. With matching_key: "email" no id lookup runs, so before a row writes its VAN id and state onto a contact we check that no other contact holds that pair. If one does, the row is still imported, withoutthe VAN id, and counted in the job’s notification.
  • What it does. A row matches an existing contact whose VAN id was stored in the same state, never one stored in another state. Each contact the import creates or matches by its VAN id keeps that state, so the next push finds them again.
  • id_state and enrichment.district_state must agree. Both describe the same list: the state of its VAN ids, and the state of its bare district numbers (003). When they differ the request is refused with id_state_district_mismatch unless it carries "confirm_id_state_mismatch": true. Bare district numbers name no state, so this is the only check such a file gets before its rows are matched.
  • Neither field is checked against the column’s contents at request time; a column name is checked against the file’s header once we fetch it (invalid_id_state_column).

Known limits

  • The district check reads whatever is in the column mapped to congressional_district: a state-senate or precinct code that looks like GA-05 there is read as a congressional district. The worst it does is refuse that row (a visible failed row, id_state_conflict); it never merges two people.
  • A job whose every row with a VAN id failed still imports its rows that have no VAN id; rows.created says how many.
  • With merge_strategy: "skip_existing"a matched contact is not changed, and its stored VAN id state is not set; but a contact matched by name and phone that had no VAN id at all is still given the row’s VAN id (as before 2026-10-05), without a state.

One state for the whole list

JSON
{
  "idempotency_key": "nc-universe-2026-10-05-01",
  "source": {"type": "s3_presigned_url", "url": "https://your-bucket.s3.us-east-1.amazonaws.com/nc.csv?<presigned query string>"},
  "field_mappings": {
    "vanid": {"field": "external_id"},
    "first": {"field": "first_name"},
    "last":  {"field": "last_name"},
    "cell":  {"field": "phone_1"},
    "zip":   {"field": "zip_code"}
  },
  "matching_key": "external_id",
  "van_ids": true,
  "id_state": "NC"
}

A list that mixes states, with the state in a voter_state column

JSON
{
  "idempotency_key": "multi-state-2026-10-05-01",
  "source": {"type": "s3_presigned_url", "url": "https://your-bucket.s3.us-east-1.amazonaws.com/all.csv?<presigned query string>"},
  "field_mappings": {
    "vanid":       {"field": "external_id"},
    "cell":        {"field": "phone_1"},
    "zip":         {"field": "zip_code"},
    "voter_state": {"field": "skip"}
  },
  "matching_key": "external_id",
  "van_ids": true,
  "id_state_column": "voter_state"
}

(voter_state is mapped to skip here because it is read only for the id; map it to state instead if it is also the address state you want stored. Without a state, zip_code, congressional_district or manual_timezone column, the 202 carries the no_location_columns warning: Where your contacts are.)

The refusal when the file maps a column to external_id and the request leaves out van_ids: true

Text
HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "van_ids_unconfirmed",
    "message": "Column 'vanid' is mapped to external_id, the VAN ID field, but the request did not confirm it holds VAN IDs, so nothing was imported. If it does, send van_ids: true together with id_state or id_state_column. If it does not, map it to a custom: field (your own id can then be the matching_key) or to skip.",
    "details": {"column": "vanid"},
    "request_id": "0f2c8e14-77a1-4a55-9a9b-2f0f18cbf1f0"
  }
}

The refusal when van_ids: true is sent but neither state field is

Text
HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "id_state_required",
    "message": "This file has VAN IDs (column 'vanid') but no VAN ID state. VAN IDs are only unique within a state, so choose the column of the file that holds each ID's state, or one state for the whole list. Send id_state (one state for the whole list) or id_state_column (the header of the column holding each row's state).",
    "details": null,
    "request_id": "0f2c8e14-77a1-4a55-9a9b-2f0f18cbf1f0"
  }
}

None of these refusals changes on retry: fix the request and send it with a new idempotency_key.

Where your contacts are (calling hours)

Since 2026-10-07. We call each contact inside legal calling hours on their own clock, and we place them by the first of these they have:

  1. a timezone (manual_timezone, or one we derived earlier) that names a region, like America/Chicago; EST, CST or UTC place no one;
  2. a state;
  3. a zip_code that places them;
  4. a congressional_district that names a state (NV-02, or a bare 002 that enrichment.district_stateor your outreach’s district gives a state).

A contact with none of these is imported and called like anyone else, but only between noon and 9 PM Eastern(9 AM to 6 PM Pacific), the hours that are legal everywhere in the lower 48 states. If such a contact’s phone number has a Hawaii, Alaska, Puerto Rico or other territory area code, that place’s legal hours must hold too, which narrows the window further. The VAN id state (The VAN id state) is not used for this: it decides who a VAN id matches, not when they are called.

We tell you, and never refuse the request for it:

WarningWhereWhen
no_location_columnsthe 202 (and its replays)field_mappings maps no column to state, zip_code, congressional_district or manual_timezone. Not sent when the request maps street_address with enrichment.enabled and enrichment.include_congressional: district enrichment can place those contacts, and contacts_unlocated says whether it did. details is null.
no_location_columnsGET /contact-lists/{id}You sent no field_mappings, and none of the headers we resolved (Automatic header resolution) is one of those four (the same street_address exception applies). Recorded once we have read the header.
contacts_unlocatedGET /contact-lists/{id}, once status is succeededSome of the list’s contacts have none of the four. details.unlocated_contacts is how many, of details.list_contacts (every live contact the list holds: created, updated or matched and left as it was).

contacts_unlocated counts people, not rows: a person listed twice in the file counts once, and a contact the organization already had is judged by everything stored for them, not only by this file. It is counted after district enrichment (enrichment.enabled), which can place people by their street address. A warning never changes status or rows.

To call those contacts on their local hours, send their state, ZIP code or congressional district in another import, with any merge_strategy except skip_existing (which changes nothing on a contact we already have).

JSON
"warnings": [
  {
    "code": "contacts_unlocated",
    "message": "1,204 of the 58,124 contacts in this list have no state, ZIP code, congressional district or timezone that places them. They were imported, and will be called only between noon and 9 PM Eastern, the hours that are legal everywhere in the lower 48 states, never on their own local hours. To change that, send their state, ZIP code or congressional district in another import (any merge_strategy except skip_existing).",
    "details": {"unlocated_contacts": 1204, "list_contacts": 58124}
  }
]

Branch on code and read the numbers from details; the message wording may change.

merge_strategy semantics

ValueFor a NEW personFor a MATCHED person
add_new_only defaultCreatedFills only fields that are currently empty. Existing values are never overwritten.
update_allCreatedOverwrites fields with your CSV values. An empty CSV cell is ignored, never written — this API cannot blank an existing value.
skip_existingCreatedNothing changes on the contact. It is still added to this list and outreach.

update_if_newer is not supported and is rejected — it is unimplemented in the importer and would silently behave as a no-op.

matching_key

ValueMeaning
external_id defaultMatch on your stable person id (the VAN id) within the id’s state, which you give as id_state or id_state_column (since 2026-10-05; required for any file with VAN ids, together with "van_ids": truesince 2026-10-07). A VAN id is unique only inside a state, so a row whose id belongs to a contact in another state is created as a new contact. The row’s address is not read for it. The right choice for voter files.
emailMatch on email address.
custom:<name> since 2026-10-08Match on the value of one of your organization’s custom fields: an id of your own that is not a VAN id. One column of the file must fill that field. See below.

Any other value is rejected. (In the underlying importer an unrecognized key means “match nothing”, which silently duplicates every contact — hence the hard rejection here.)

Matching on your own id (custom:<name>)

Since 2026-10-08. If the id you key your sync on is not a VAN id, keep it in a custom field and name that field as the matching key:

JSON
{
  "idempotency_key": "members-2026-10-08-01",
  "source": {"type": "s3_presigned_url", "url": "https://your-bucket.s3.us-east-1.amazonaws.com/members.csv?<presigned query string>"},
  "field_mappings": {
    "voterbase_id": {"field": "custom:voterbase_id"},
    "first":        {"field": "first_name"},
    "last":         {"field": "last_name"},
    "cell":         {"field": "phone_1"},
    "zip":          {"field": "zip_code"}
  },
  "matching_key": "custom:voterbase_id",
  "merge_strategy": "update_all"
}

No van_ids, no id_state: this id is not a VAN id, and we never treat it as one. You choose the field; we never pick a matching key for you.

What makes a custom field usable as the key.These are the rules of Meteoric’s own upload screen, checked the same way here:

  • Exactly one column fills it. With field_mappings, one column must be mapped to that custom: field. None is 400 custom_matching_key_unusable (the message names the field). Two columns on one field are refused too: two spellings of its name as 400 invalid_field_mappings (field_mappings), its name from one column and its key from another as 400 custom_matching_key_unusable. The name follows the rules of any custom: target (Field targets): case, spaces, hyphens and underscores are interchangeable, and a field’s key from GET /field-schema works too.
  • Its values tell people apart. At least 90% of the values in the file must be different (read on its first 10,000 rows). A file that lists a few people twice passes; a county, a party or a household id does not: matched on, everyone sharing a value would become one person.
  • At least half of the rows have it. A column with no value on any row cannot be the key, and neither can one with a value on fewer than halfof the file’s rows (counted on the whole file; a row with nothing in any cell is not counted): that is a wrong column or a broken export far more often than a list, and nearly every row would be matched by name and phone number without a word. The message gives the count. A list where some people have no id yet still imports (see below).
  • Never row numbers. A column that counts 1, 2, 3… is not an id, and a custom field named like a row number (row_number, row, line, index, #…) cannot be the key (row_number_matching_key).
  • The field’s name is checked at the request; the file when we read it. A file that breaks the 90% rule or the half-of-the-rows rule, or holds row numbers, fails as a job with custom_matching_key_unusable or row_number_matching_key, before any row is imported. So does a file that has no column with the exact header your field_mappings put on the key.
  • Send field_mappings with a custom key. Without them we read a header as a custom field only when it is the name of a field your organization already has (Automatic header resolution). A header like voter_id or id is read as external_id instead, and the job fails with custom_matching_key_unusable; a header we cannot read at all fails it with mapping_unresolvable.

What it does.

  • A row matches the contact in your organization whose value for that field is the row’s (compared exactly, after trimming spaces), in any state. The usual merge_strategy then applies.
  • A row whose value nobody holds is matched by name and phone number, the rule every import ends with: a contact you loaded earlier without the id is found that way and gains the id, so your first push on this key fills it in. No match either: a new contact is created, holding the id.
  • That name-and-phone match is refused when the contact it finds already holds another value of the field: by your own key they are two people. The row is not imported; it is in the failed-rows file with both values (_category duplicate) and counts in rows.failed.
  • The same value on several rows of the file is one person: the rows are merged onto one contact.
  • If several contacts in your organization already hold a row’s value, the row goes to the one with the row’s phone number, else the oldest record.
  • A row with no value in that column is matched by name and phone number (at least half of the file’s rows must have one, or the job fails).
  • A file with a custom key and a column on external_id still follows The VAN id state for that column (van_ids: true, and id_state or id_state_column).
  • If matching on a custom field is switched off on our side, the request is refused with 400 custom_matching_key_disabled. A job accepted before that fails with the same code, when we read its file or when its import starts, rather than being imported without its key.

Attaching to a campaign (outreach_id)

outreach_id accepts outreaches in draft, generating, paused and active status — attaching to a live outreach is supported and behaves like any other contact upload (standard dedupe, suppression and compliance checks apply through the shared pipeline). completed and archived outreaches are refused with outreach_not_accepting_contacts.

An outreach that does not exist and an outreach belonging to a different organization return the same 404 outreach_not_found.

Response — 202 Accepted

Location: /api/partner/v1/contact-lists/91744is also set. Nothing of yours has been fetched at the moment we answer — this response means “accepted and queued”. Poll status_url.

status here is normally queued; if a worker has already picked the job up by the time the response is serialized you will see fetching, as in the example. Both mean the same thing to you: start polling.

If your organization already has 3 imports running, the job is still accepted and waits its turn: queue_position is its place in the line (1 = next), and null means it is not waiting. Up to 10 jobs can wait. Your file is fetched immediately either way, so a presigned URL can never expire while a job waits.

warnings is what the import accepted but wants you to know, each {"code", "message", "details"} (details is an object or null); never a refusal, and empty when there is nothing to say. id_state_ignored: your organization’s VAN ids are unique nationwide (The VAN id state), so id_state / id_state_column were ignored. no_location_columns: field_mappings maps no state, ZIP, congressional district or timezone column, so contacts with none stored are called only between noon and 9 PM Eastern (Where your contacts are). A replay returns the warnings of the original request.

JSON202 Accepted
{
  "id": 91744,
  "status": "fetching",
  "organization_id": 27,
  "list_name": "GA Universe 2026-09",
  "outreach_id": 4821,
  "idempotency_key": "moveon-ga-2026-09-03-01",
  "source_url_expires_at": "2026-09-03T18:00:00",
  "created_at": "2026-09-03T17:00:04.139071",
  "status_url": "/api/partner/v1/contact-lists/91744",
  "poll_after_seconds": 5,
  "queue_position": null,
  "replayed": false,
  "warnings": []
}

§4

GET /contact-lists/{id}

GET/api/partner/v1/contact-lists/{id}120 / minute

The status of one job. This is the completion signal — there is no webhook.

Poll every poll_after_seconds (5) until status is succeeded or failed.

JSON200 OK
{
  "id": 91744,
  "organization_id": 27,
  "status": "succeeded",
  "list_name": "GA Universe 2026-09",
  "filename": "ga-2026-09.csv",
  "outreach_id": 4821,
  "idempotency_key": "moveon-ga-2026-09-03-01",
  "created_at": "2026-09-03T17:00:04.139071",
  "started_at": "2026-09-03T17:00:05.402118",
  "completed_at": "2026-09-03T17:03:41.778904",
  "rows": {
    "total": 58231,
    "processed": 58231,
    "created": 51002,
    "updated": 6720,
    "skipped": 402,
    "suppressed": 88,
    "failed": 19
  },
  "field_mappings": {
    "vanid": {"field": "external_id"},
    "cell": {"field": "phone_1"}
  },
  "auto_mapped": true,
  "source": {
    "host": "moveon-lists.s3.us-east-1.amazonaws.com",
    "key": "universes/ga-2026-09.csv",
    "bytes": 14829312,
    "encoding": "utf-8-sig"
  },
  "error": null,
  "failed_rows_csv_url": "https://fortress-uploads.s3.us-east-2.amazonaws.com/...",
  "status_url": "/api/partner/v1/contact-lists/91744",
  "queue_position": null,
  "poll_after_seconds": 5,
  "warnings": [
    {
      "code": "contacts_unlocated",
      "message": "1,204 of the 58,124 contacts in this list have no state, ...",
      "details": {"unlocated_contacts": 1204, "list_contacts": 58124}
    }
  ]
}

Status vocabulary

statusMeaningTerminal?
queuedAccepted. Either we have not fetched your URL yet, or the file is copied and waiting for one of your organization’s 3 import slots — queue_position says where it is.no
fetchingDownloading from your presigned URL.no
importingReading rows, matching, creating/updating contacts.no
enrichingContacts are in; district/geo enrichment is running.no
succeededDone.yes
failedDone, unsuccessfully. See error.code.yes

Row counts

KeyMeaning
totalRows in the file. null while queued or fetching — we have not read it yet. Once the import has finished it leaves out blank rows (no value in any column): they are skipped, and the blank_rows_skipped warning says how many there were.
processedRows read so far.
createdNew contacts created.
updatedExisting contacts whose fields changed.
skippedRows deliberately not applied (e.g. skip_existing matches, in-file duplicates).
suppressedRows dropped because the person is on the organization’s suppression/opt-out list.
failedRows we did not import, each one in the failed-rows file with its reason — this is where strict-NANP phone rejections land, rows with a VAN id whose id_state_column cell is blank or not a state (The VAN id state), rows that match nobody and have no name and no phone number (since 2026-10-08, _category missing_contact_info), and (counted here since 2026-10-08, for jobs finished before that date too when you read them again) rows refused because they match a contact who already holds a different id (_category duplicate). Suppressed rows are in the failed-rows file too (_category suppressed) and are counted in suppressed, not here.

Other fields

  • field_mappings / auto_mapped — the mappings actually used. auto_mapped is true when we resolved headers ourselves.
  • source — host and object key only. We never echo your presigned URL or any part of its query string. source.encoding is the encoding we detected (utf-8, utf-8-sig for a BOM’d export, windows-1252, …).
  • failed_rows_csv_url — present when rows failed or were suppressed. It is a freshly minted, 15-minute presigned link generated at the moment you read the status. Do not cache it; re-read the status to get a new one.
  • queue_position— the job’s place in your organization’s import queue while it is waiting for a slot (1 = next); null whenever it is not waiting.
  • error — null unless status is failed. See Errors.
  • warnings — the 202’s warnings, then any recorded since: no_location_columns when we resolved your headers ourselves, and contacts_unlocated once status is succeeded (Where your contacts are), and row_number_columns_skipped when we skipped columns of row numbers (Automatic header resolution), and blank_rows_skipped when rows of the file had no value in any column (details.blank_rows; they are skipped, counted in no rows number and not in the failed-rows file). Never a failure; empty when there is nothing to say.

The failed-rows CSV is your original columns plus a _error_reason column, so you can fix and re-push exactly the rows that bounced:

Textfailed-rows.csv
last,cell,zip,first,vanid,_error_reason
Carter,(404) 555-0134,30303,Ada,10029381,Error: Invalid US phone format: (404) 555-0134

§5

CSV contract and field targets

  • UTF-8 preferred; Windows-1252 / Latin-1 exports are tolerated. The encoding we detected is reported back in source.encoding.
  • First line must be a header row. Comma-delimited.
  • Maximum 50 MB, 250,000 rows, 120 columns, 30 new custom fields per import. Call GET /field-schema for the live values.
  • Cells are sanitized against spreadsheet formula injection on the way in.

field_mappings

An object keyed by your CSV header, valued by {"field": "<target>"}:

JSON
"field_mappings": {"vanid": {"field": "external_id"}, "cell": {"field": "phone_1"}}

Rules:

  • Every target must be from the table below, or the special form custom:<name>, or skip.
  • If you supply field_mappings at all, at least one column must map to a phone slot — a list with no phone numbers cannot be dialed.
  • A non-phone target may be used by only one column (use skip for the others). skip may repeat freely.
  • At most 30 distinct custom: targets, and two columns may not target the same custom field — even in different casing.
  • {"field": ...} is the only key accepted inside the per-column object.

Field targets

TargetNotes
first_name
last_name
emailAlso usable as matching_key.
external_idYour stable person id (VAN id). Default matching_key. A file that maps it must send van_ids: true and id_state or id_state_column (The VAN id state). Not for a row number (use skip) or another kind of id: keep that in a custom: field and, if it is your stable id, send that field as matching_key.
street_addressFeeds district enrichment.
city
stateTwo-letter code.
zip_codeAlso feeds the single-CD ZIP district fallback.
congressional_districtPre-enriched value, if you have one.
state_senate_district
state_house_district
manual_timezoneA region name, e.g. America/Chicago (EST or UTCplace no one). Only set when you know better than the contact’s state or ZIP: your campaign’s hours then run on this clock, and the legal hours must still hold on the state’s or ZIP’s.
phone_1 … phone_6Strict NANP — see the warning at the top.
custom:<name>Writes into an organization custom field. The name is matched case-insensitively against your existing custom fields’ names and keys (spaces, hyphens and underscores are interchangeable); if nothing matches, the field is created. GET /field-schema lists what already exists. A custom field holding your own id for each person can also be the matching_key (since 2026-10-08, Matching on your own id).
skipIgnore this column.

There is no target for internal-only or superuser-only fields; they are not reachable through this API.

state, zip_code, congressional_district and manual_timezone decide the hours each contact is called in; a file with none of them gets a warning (Where your contacts are).

Automatic header resolution

If you omit field_mappings we resolve headers deterministically— an alias table plus your organization’s existing custom fields. No AI guessing runs on this path. Header matching lowercases and replaces spaces with underscores. Columns we cannot resolve fail the job with mapping_unresolvable, naming them (a column of row numbers is skipped instead, see below).

A VAN export usually needs no mappings at all — but it does need two things said: when we resolve a column to external_id, send "van_ids": true and id_state or id_state_column with the request (The VAN id state), or the job fails once we have read the header (van_ids_unconfirmed, then id_state_required).

A header named just id resolves to external_id (unless it holds row numbers, below). If your id column is your own person id rather than VAN ids, send field_mappings that map it to a custom: field, and name that field as matching_key if it is the id you sync on (Matching on your own id); without van_ids: true the job fails with van_ids_unconfirmed, naming the column, and nothing is imported.

With matching_key: "custom:<name>", send field_mappings. Without them one header must be read as that custom field, which happens only when the header is the name of a field your organization already has: not its key, and not a header from the table below (voter_id and id are read as external_id). A header we cannot read at all fails the job with mapping_unresolvable; a file whose headers all resolve, but none to that field, fails with custom_matching_key_unusable.

A column of row numbers is skipped (since 2026-10-07), whatever its header, idincluded: every row with any value has a whole number there, the first is 0 or 1, and each row’s is the one before plus 1. Nothing else is read that way. The job’s status then warns row_number_columns_skipped, naming the columns in details.columns. If you send field_mappings yourself, a row-number column on external_id or on your matching_key fails the job (row_number_matching_key); keep it, if you want it, as custom:row_number.

Your headerResolves to
vanid, van_id, ngpvan_id, voter_id, voterid, external_id, idexternal_id
first_name, firstname, first, fnamefirst_name
last_name, lastname, last, lname, surnamelast_name
email, email_address, e-mailemail
phone, phone_number, cell, cell_phone, mobilephone_1
home, home_phonephone_2
work, work_phonephone_3
address, address1, street, street_address, mailing_addressstreet_address
city, mailing_citycity
state, mailing_statestate
zip, zipcode, zip_code, postal, postal_codezip_code
cd, congressional_districtcongressional_district
timezone, tz, time_zonemanual_timezone

GET /field-schema returns the complete, authoritative alias table as JSON — build against that rather than pasting this one into your code.

§6

Errors

Every error, from every endpoint, has one shape.

Branch on code. message is for humans and may be reworded. Quote request_id when you contact support.

JSONError shape
{
  "error": {
    "code": "outreach_not_found",
    "message": "No outreach with that id exists in this organization.",
    "details": {"outreach_id": 4821},
    "request_id": "0f2c8e14-77a1-4a55-9a9b-2f0f18cbf1f0"
  }
}

Synchronous errors

Returned by the request itself.

HTTPcodeMeaning / fix
401unauthorizedToken absent, malformed, revoked or expired. One message for all of them by design.
403forbiddenThe token’s scopes or its user’s role do not permit this request (contact import for POST /contact-lists; contacts view, import or create for the two list reads).
403token_principal_not_permittedThe token’s user is a platform superuser. Superuser tokens are refused.
403org_mismatchYou sent an X-Org-Idthat is not the token’s organization. Stop sending it.
403organization_closedThe organization is closed.
400invalid_source_urlNot an https URL on a bucket’s own s3[.region].amazonaws.com host, or credentials embedded in the URL.
400not_a_presigned_urlThe SigV4 query parameters are missing. Presign it.
400source_url_expiredThe URL has expired, or expires too soon to be useful. Presign for ≥15 minutes and post promptly.
400invalid_field_mappingsBad shape, unknown target, no phone target, duplicate target, or too many custom: targets. details names the column.
400invalid_matching_keyNot external_id, email or a well-formed custom:<name> (1–64 characters, no control characters).
400custom_matching_key_unusablematching_key names a custom field that field_mappings does not fill from exactly one column: no column is mapped to it (the message names the field), or several are (details.columns); or the field’s name has custom: inside it. Map one column to that custom: field, or choose another key. No job was created, so the same idempotency_key can be used again.
400row_number_matching_keymatching_key names a custom field that is named like a row number (row_number, row, line, index, #…). A row number names nobody; choose another key.
400custom_matching_key_disabledMatching on a custom field is switched off on our side. Use external_id or email, or contact us.
400invalid_merge_strategyNot add_new_only, update_all or skip_existing.
400van_ids_unconfirmedfield_mappings maps a column to external_id (a VAN id) and the request did not send "van_ids": true (The VAN id state). details.column names it. If it holds VAN ids, send van_ids: true with id_state or id_state_column; if not, map it to a custom: field (your own id can then be the matching_key) or to skip. No job was created, so the same idempotency_key can be used again.
400id_state_requiredfield_mappings maps a column to external_id (a VAN id) but neither id_state nor id_state_column was sent. Send one, with a new idempotency_key.
400id_state_ambiguousBoth id_state and id_state_column were sent. Send exactly one.
400invalid_id_stateid_state is not a US state, DC or a territory (PR, GU, VI, AS, MP).
400id_state_district_mismatchid_state names one state and enrichment.district_state(the state of the file’s bare district numbers) another. Check both; if both are right, resend with "confirm_id_state_mismatch": true and a new idempotency_key.
400invalid_district_stateenrichment.district_state is not a US state or territory (a 2-letter code or a name).
400outreach_not_accepting_contactsThe outreach is completed or archived.
404outreach_not_foundNo such outreach in your organization.
404contact_list_not_foundNo such job in your organization.
409idempotency_key_reusedSame key, different body. Use a new key, or resend the identical body to replay.
409import_queue_full3 imports running and 10 already waiting. details.retry_after_seconds and the Retry-After header tell you when to retry.
422invalid_requestSchema violation — unknown key, wrong type, bad idempotency_key format. details.fields lists them.
429rate_limitedSlow down; honor Retry-After.
500internal_errorOur fault. Retry; if it persists, send us the request_id.

Asynchronous errors

Reported as status: "failed", in error.code.

codeMeaning / fix
source_url_expiredThe presign had expired by the time we fetched. Presign for ≥15 minutes and post the job immediately.
source_url_forbiddenS3 returned SignatureDoesNotMatch or AccessDenied. Almost always (a) a query parameter was appended after signing, or (b) the URL was re-encoded.
source_not_foundS3 returned 404 for that key.
source_region_mismatchThe bucket is in a different region than the URL’s endpoint. Presign against the bucket’s own region.
source_unreachableWe could not fetch it after retries.
source_too_largeOver the 50 MB cap. Split the file.
source_not_csvThe bytes are not a plain-text CSV (gzip, xlsx, PDF and HTML are detected and rejected). Upload an uncompressed .csv.
csv_no_headerNo usable header row.
csv_no_rowsHeader present, no data rows.
csv_too_many_rowsOver the row cap.
csv_too_many_columnsOver the column cap.
mapping_unresolvableSome columns could not be matched to a field. Send explicit field_mappings for them, or rename them.
van_ids_unconfirmedWe resolved your headers (Automatic header resolution), read a column as external_id (a VAN id; a header named just id counts), and the request did not send "van_ids": true. Nothing was imported; the message names the column. If it holds VAN ids, send a new request, with a new idempotency_key, with van_ids: true and id_state or id_state_column; if not, rename the column, or send field_mappings for every column of the file with this one on a custom: field or on skip (columns left out of field_mappings are not imported).
id_state_requiredWe resolved your headers, found a VAN id column, and the request had neither id_state nor id_state_column. Nothing was imported. Send a new request, with a new idempotency_key, that gives one. A retry of the same request fails the same way.
invalid_id_state_columnid_state_column is not a header of the file, or is the VAN id column itself. Nothing was imported. Send a new request, with a new idempotency_key, naming the right header.
id_state_missingNo row with a VAN id was imported: each had a blank or unrecognised value in id_state_column (or a congressional district in another state). Rows without a VAN id were; rows says how many. Fix that column and push the file again with a new idempotency_key.
id_state_conflictNo row with a VAN id was imported: each row’s congressional district names another state than its VAN id state. Check id_state / id_state_column and push again with a new idempotency_key.
row_number_matching_keyA column your own field_mappings put on external_id or on your matching_key (a custom: key included) holds row numbers (1, 2, 3… or 0, 1, 2…), not ids. Matching on it would merge different people, so nothing was imported; the message names the column. Map it to skip or custom:row_number and send a new request with a new idempotency_key.
custom_matching_key_unusableThe custom field named as matching_keycannot be this file’s key, so nothing was imported. The message says which: fewer than 90% of its values in the file are different; its column is blank, or has a value on fewer than half of the file’s rows; no header was read as that field (you sent no field_mappings; a header we cannot read at all is mapping_unresolvable instead); the column your field_mappings put on it is not a header of the file; or more than one column fills it. Fix the file or its mapping, or choose another matching_key, and send a new request with a new idempotency_key.
custom_matching_key_disabledMatching on a custom field was switched off on our side after the request was accepted, before its import started. Nothing was imported. Send a new request, with a new idempotency_key, on external_id or email, or contact us.
same_file_already_importedThe file’s bytes are exactly those of a file your organization already imported, through this API or Meteoric’s upload screen (since 2026-10-08). Nothing was imported; the message names the earlier job and the day it was imported. The same file is imported once per organization, whatever the request’s other fields (outreach_id, field_mappings…): a file that differs in any byte can be sent in a new request with a new idempotency_key. Files imported before 2026-10-08 were never fingerprinted and do not count.
same_file_importingThe same file is already waiting or importing in your organization as another job, which the message names with the day it was accepted. Nothing was imported by this job. If that job fails, send the file again in a new request with a new idempotency_key.
invalid_source_urlThe URL no longer passed validation when the import ran. Also a synchronous code — see above.
not_a_presigned_urlThe URL’s signature parameters were missing when the import ran. Also a synchronous code — see above.
internal_errorOur fault. Send us the job id.

invalid_source_url and not_a_presigned_url appear in both tables on purpose: we re-validate the URL immediately before fetching it, so a URL that passed at request time can still be rejected at execution time.

§7

Idempotency

idempotency_key is required. The worst failure this API can have is importing the same 500,000-row universe twice into a live dial program, so there is no unkeyed path.

  • Keys are scoped to your organization.
  • We fingerprint the request body with the URL’s query string removed — so re-signing the same object and retrying is recognised as the same request, not a new one.
  • Same key + same body → 200 with "replayed": trueand the original job’s id. Safe to retry after a timeout.
  • Same key + different body → 409 idempotency_key_reused.
  • Two requests racing with the same key produce one job, not two.
  • A replay is still returned when your organization’s import queue is full — you can always recover a job id you already own.
  • A replay is returned before any check added since the job was accepted: a retry of a request we accepted before 2026-10-05 (a VAN id and no id_state/id_state_column) gets its original job back, not id_state_required. A new key with that body is refused. The same holds for a request accepted before van_ids (2026-10-07) that did not send it, and for a request on a custom: matching key: its retry returns its job even if a check on that key would refuse a new request now.
  • matching_key is part of the fingerprint, as it always was: the same key with another matching_key is 409 idempotency_key_reused.
  • New key + a file identical to one your organization already has (since 2026-10-08) → accepted, then the job fails once we have fetched the file, naming the earlier job (Asynchronous errors): same_file_already_imported when that job imported it, same_file_importingwhile it is still waiting or importing. The fingerprint is of the request; this check is of the file’s bytes. A replay of an accepted request is never refused by it.

Pick keys that encode the push, e.g. moveon-ga-2026-09-03-01. A weekly re-push of the same filename with new content is a new key. A re-push whose content has not changed at all is refused as same_file_already_imported, and so is the same file sent with another outreach_id, field_mappings, matching_key or merge_strategy: the check looks only at the file.

§8

Presigning the object

Presign for at least 15 minutes (we recommend 1 hour) and POST the job right away. All three samples below do the same three things: presign for an hour, create the import, poll until the job is terminal.

Shellimport.sh
# 1) Presign the CSV (GET-signed, 1 hour)
URL=$(aws s3 presign s3://moveon-lists/universes/ga-2026-09.csv \
        --expires-in 3600 \
        --region us-east-1)

# 2) Create the import
curl -sS -X POST https://api.getmeteoric.io/api/partner/v1/contact-lists \
  -H "Authorization: Bearer $METEORIC_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg url "$URL" '{
        idempotency_key: "moveon-ga-2026-09-03-01",
        source: {type: "s3_presigned_url", url: $url},
        list_name: "GA Universe 2026-09",
        outreach_id: 4821,
        matching_key: "external_id",
        merge_strategy: "add_new_only",
        van_ids: true,
        id_state: "GA"
      }')"

# 3) Poll to completion
JOB=91744
while :; do
  BODY=$(curl -sS "https://api.getmeteoric.io/api/partner/v1/contact-lists/$JOB" \
          -H "Authorization: Bearer $METEORIC_TOKEN")
  echo "$BODY" | jq -r '.status, .rows'
  case "$(echo "$BODY" | jq -r .status)" in
    succeeded|failed) break ;;
  esac
  sleep 5
done

The Node sample needs @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner; fetch is built in from Node 18.

Requirements for the URL

  • https only, on the bucket’s own …s3[.<region>].amazonaws.com host.
  • Signed for GET.
  • No credentials embedded in the URL (no user:pass@).
  • Only public AWS S3 endpoints — not our buckets, not third-party S3-compatible hosts.

§9

Traps — the five things that actually break integrations

  1. Never append a query parameter to a presigned URL. Not &download=1, not a cache-buster. Any added parameter invalidates the SigV4 signature and S3 answers 403 → source_url_forbidden.
  2. Never re-encode the URL. Send the string your SDK/CLI produced, byte-for-byte. Re-quoting %2F, normalizing +, or round-tripping it through a URL builder breaks the signature the same way.
  3. Presign for at least 15 minutes, and post the job immediately. A URL that is already expired — or expires within two minutes — is refused synchronously (source_url_expired) so you find out at request time.
  4. We only ever issue a GET. We do not HEAD your object to check reachability: a HEAD against a GET-signed URL always 403s, so the check would be pure noise. Sign for GET, and expect exactly one fetch — the file is copied on first read, so retries never touch your URL again.
  5. One 50 MB cap, and polling is the completion signal. 50 MB is the ceiling everywhere (API and UI). There is no webhook and no callback: poll status_url every 5 seconds until succeeded or failed.

§10

Reconciliation — GET /contact-lists

GET/api/partner/v1/contact-lists30 / minute

Recent jobs, newest first. The endpoint you reconcile a night of pushes against.

Text
GET /api/partner/v1/contact-lists?limit=25&offset=0

Newest first; limit max 100. Returns summary rows (id, status, name, filename, outreach, idempotency key, timestamps, row counts, error). For resolved mappings, source detail, warnings and the failed-rows download, fetch the individual job.

JSON200 OK
{
  "items": [
    {
      "id": 91744,
      "status": "succeeded",
      "list_name": "GA Universe 2026-09",
      "filename": "ga-2026-09.csv",
      "outreach_id": 4821,
      "idempotency_key": "moveon-ga-2026-09-03-01",
      "created_at": "2026-09-03T17:00:04.139071",
      "started_at": "2026-09-03T17:00:05.402118",
      "completed_at": "2026-09-03T17:03:41.778904",
      "rows": {"total": 58231, "processed": 58231, "created": 51002,
               "updated": 6720, "skipped": 402, "suppressed": 88, "failed": 19},
      "error": null,
      "status_url": "/api/partner/v1/contact-lists/91744"
    }
  ],
  "count": 1,
  "limit": 25,
  "offset": 0
}

§11

GET /field-schema and GET /whoami

The two discovery endpoints. Neither one writes anything, and both are the right place to start an integration.

GET /field-schema

GET/api/partner/v1/field-schema60 / minute

The live field vocabulary.

Returns the live vocabulary — targets, the full deterministic alias table, your organization’s existing custom fields, matching keys, merge strategies with one-line semantics, and current limits. Build against it instead of hard-coding the CSV contract.

custom_fields is the list of custom fields your organization already has, each with its display name, machine key and type. Reuse one of those names (or keys) in field_mappings and the import writes into the existing field; only a name that matches nothing creates a new one.

matching_keys lists external_id, email and, since 2026-10-08, custom:<field name>: any one of those custom fields, or one the import creates, named as custom:<name> (matching_key).

JSON200 OK
{
  "targets": [{"name": "external_id", "description": "..."}],
  "special_targets": [{"name": "custom:<field name>", "description": "..."},
                      {"name": "skip", "description": "..."}],
  "custom_fields": [{"name": "Support Level", "key": "support_level", "type": "text"},
                    {"name": "Precinct", "key": "precinct", "type": "text"}],
  "aliases": {"vanid": "external_id", "cell": "phone_1"},
  "matching_keys": [{"name": "external_id", "description": "..."}],
  "merge_strategies": [{"name": "add_new_only", "description": "..."}],
  "limits": {"max_bytes": 52428800, "max_rows": 250000, "max_columns": 120,
             "max_custom_fields": 30, "max_active_imports": 3,
             "max_queued_imports": 10}
}

GET /whoami

GET/api/partner/v1/whoami60 / minute

Which organization a key writes into.

GET /whoami confirms which organization a key writes into — the first call to make with a new token.

scopesis the token’s own scope map. Effective permission is the intersection of the token’s scopes and the service user’s role, so a scope listed here is not by itself a grant.

JSON200 OK
{
  "organization": {"id": 27, "name": "MoveOn"},
  "user": {"id": 812, "email": "api+moveon@getmeteoric.io"},
  "role": "owner",
  "scopes": {"contacts": ["import", "view"], "upload_status": ["view"]},
  "limits": {"max_bytes": 52428800, "max_rows": 250000, "max_columns": 120,
             "max_custom_fields": 30, "max_active_imports": 3,
             "max_queued_imports": 10},
  "rate_limits": {"POST /contact-lists": "10/minute; 100/day"}
}

Support

Email support@getmeteoric.io with the request_id (synchronous failures) or the job id (asynchronous failures) for anything this document does not cover.

Changelog

  1. v1

    Initial Contact Lists v1 contract published.

  2. v1

    Self-serve API keys (Settings → API keys); timestamps and response examples corrected against a live run.

  3. v1

    GET /field-schema lists your organization's existing custom fields; custom:<name> matches case-insensitively against names and keys.

  4. v1

    Naming note: the app says campaign, the API says outreach (outreach_id, outreach_* error codes); campaign-named equivalents will be added alongside, and the outreach names stay supported at least through November 2026.

  5. v1

    Imports beyond the 3 running per organization are now queued (up to 10 waiting, first-in first-out) instead of refused; queue_position added to responses; 409 import_queue_full replaces too_many_active_imports.

  6. v1

    enrichment.district_state: the state your file's bare congressional-district numbers belong to (VAN writes NV-02 as 002).

  7. v1

    A file with a VAN id column must say which state the ids are in: id_state (one state) or id_state_column (a column of the file). Without one the request is refused (400 id_state_required), or, when we map the header ourselves, the job fails with id_state_required. New errors id_state_ambiguous, invalid_id_state, id_state_district_mismatch, invalid_id_state_column, id_state_missing, id_state_conflict; warnings on the 202 (id_state_ignored).

  8. v1

    The two list reads (GET /contact-lists/{id} and GET /contact-lists) need a key with a contacts scope: view, import or create (any one). Before, any key of the organization could read them. A key that imports lists can always read them.

  9. v1

    Warnings about contacts with no location: no_location_columns on the 202; a warnings list on GET /contact-lists/{id}, with contacts_unlocated once the job has succeeded; a details field (an object or null) on every warning. Contacts with no state, ZIP code, congressional district or timezone are called only between noon and 9 PM Eastern. Nothing is refused for it.

  10. v1

    A file with a column on external_id must send van_ids: true to confirm the column holds VAN ids. Without it the request is refused (400 van_ids_unconfirmed), or, when we map the header ourselves, the job fails with van_ids_unconfirmed; both name the column.

  11. v1

    A column of row numbers (1, 2, 3… or 0, 1, 2…) is skipped when we resolve your headers, whatever its name, with a row_number_columns_skipped warning on the status; mapped by you to external_id or your matching_key, the job fails with row_number_matching_key.

  12. v1

    matching_key accepts custom:<name>: one of your organization's custom fields, for an id of your own that is not a VAN id (one column of the file fills it, at least 90% of its values in the file are different, at least half of the file's rows have a value in it, never row numbers; no van_ids or id_state). New codes custom_matching_key_unusable and custom_matching_key_disabled, on the request (400) or on the job; row_number_matching_key can now also be a 400. Nothing changes for external_id and email requests.

  13. v1

    rows.failed now also counts rows refused because they match a contact who already holds a different id (_category duplicate); before, those rows were in no rows number. It applies to jobs finished earlier too when you read them again. Suppressed rows, also in the failed-rows file, stay in rows.suppressed.

  14. v1

    A blank row of the file (no value in any column, or only its row number) is skipped; before, each one became a contact named Unknown Unknown. Once the import has finished, blank rows are in no rows number and not in the failed-rows file, and the status carries the warning blank_rows_skipped with how many there were.

  15. v1

    A row that matches nobody in your organization and has no name and no phone number is not imported (before, it became a contact named Unknown Unknown): it counts in rows.failed and is in the failed-rows file with the reason 'Missing required contact info: no name or phone number' (_category missing_contact_info). A row that matches a contact you already have still updates them.

  16. v1

    The same file is imported once per organization. A file whose bytes are exactly those of a file your organization already imported (through this API or Meteoric's upload screen) is still accepted (202), then the job fails before any row is imported with same_file_already_imported, naming the earlier job and the day it was imported, or with same_file_importing while that earlier copy is still waiting or importing. The file alone decides: the same file sent with another outreach_id, field_mappings, matching_key or merge_strategy is refused too. A job that failed for any reason never counts, and files imported before 2026-10-08 were never fingerprinted and do not count. A retry with the same idempotency_key and body still returns its original job.

Contract source new-dialer-2025/docs/public-api/contact-lists-v1.md (reviewed 2026-10-08).

Rendered from the canonical contract in new-dialer-2025/docs/public-api/contact-lists-v1.md. Something unclear or wrong? support@getmeteoric.io.