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.
§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.
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.
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.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.POST the job
POST /contact-listswith anidempotency_keyand the presigned URL, plusvan_ids: trueandid_stateorid_state_columnif the file has VAN ids (see The VAN id state). You get202 Acceptedand astatus_urlback — nothing has been fetched yet.Poll to completion
GET /contact-lists/{id}every 5 seconds untilstatusissucceededorfailed. 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:
Authorization: Bearer mtk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX- Tokens are prefixed
mtk_live_. Every key acts on your organization’s live data — verify a new key with/whoamibefore importing anything. - The token carries its organization. There is no
X-Org-Idheader to set — if you send one and it disagrees with the token’s organization the request is refused withorg_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 unauthorizedwith 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
curl -sS https://api.getmeteoric.io/api/partner/v1/whoami \
-H "Authorization: Bearer $METEORIC_TOKEN"§2
Endpoints
Five operations, all under /api/partner/v1.
| Method | Path | Purpose | The key needs |
|---|---|---|---|
| POST | /contact-lists | Create a bulk-load job from a presigned S3 CSV URL | contacts: import |
| GET | /contact-lists/{id} | Status of one job | contacts: view, import or create (any one) |
| GET | /contact-lists | Recent jobs, paged (reconciliation) | contacts: view, import or create (any one) |
| GET | /field-schema | Machine-readable field vocabulary | any key |
| GET | /whoami | Which organization/user this key acts as | any 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
| Endpoint | Limit |
|---|---|
POST /contact-lists | 10 / minute, 100 / day |
GET /contact-lists/{id} | 120 / minute |
GET /contact-lists | 30 / minute |
GET /field-schema, GET /whoami | 60 / 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
/api/partner/v1/contact-lists10 / minute · 100 / dayCreate a bulk-load job from a presigned S3 CSV URL. Returns immediately; the fetch and import happen in the background.
Request
| Field | Required | Notes |
|---|---|---|
idempotency_key | Required | 8–64 chars of A–Z a–z 0–9 . _ : -. See Idempotency. |
source.type | Optional | Only s3_presigned_url today. |
source.url | Required | A presigned GET URL. See Presigning the object. |
list_name | Optional | Display name in Meteoric. Defaults to the S3 object’s filename. |
outreach_id | Optional | Attach the imported contacts to this outreach (a campaign in the app). See Attaching to a campaign. |
field_mappings | Optional | Omit to let us resolve headers automatically (see CSV contract). |
matching_key | Optional | external_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_ids | Optional | true 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_state | Optional | One 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_column | Optional | One 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_mismatch | Optional | true to go ahead when id_state differs from enrichment.district_state. See The VAN id state. |
merge_strategy | Optional | add_new_only (default), update_all, skip_existing. |
enrichment.* | Optional | Defaults as shown in the example. |
enrichment.district_state | Optional | The 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.
{
"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:
| Field | Use it when | Value |
|---|---|---|
id_state | Every 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_column | The 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 onexternal_idis always read as VAN ids, including one we map for you from a header named justid(unless it holds row numbers: we skip those, see Automatic header resolution). Withoutvan_ids: truethe request is refused with400 van_ids_unconfirmed, before the state is asked; when we map the header ourselves, the job fails withvan_ids_unconfirmedonce 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 acustom:field and, if it is your stable id for each person, send that field asmatching_key(Matching on your own id).van_ids: trueon a file with no such column is ignored. - Exactly one. Neither (with a VAN id column) is
id_state_required; both isid_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 sendsid_stateorid_state_columnanyway is accepted, the value is ignored (not checked), and the202says so inwarningswith the codeid_state_ignored. - Your address is not used for it.
state,zip_codeandcongressional_districtdescribe the address, which is often a mailing address in another state. If yourstatecolumn really is each voter’s registration state, name it inid_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 inrows.failedand is in the failed-rows file with the reason (_categoryno_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_districtcell names its own state (GA-05,ga 5,Georgia 5— not a bare005) and that state is not the row’s VAN id state, the row is not imported either (rows.failed,_categoryid_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_missingwhen any had a blank or unrecognised cell, elseid_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_stateandenrichment.district_statemust 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 withid_state_district_mismatchunless 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 likeGA-05there 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.createdsays 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
{
"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
{
"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
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
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:
- a timezone (
manual_timezone, or one we derived earlier) that names a region, likeAmerica/Chicago;EST,CSTorUTCplace no one; - a
state; - a
zip_codethat places them; - a
congressional_districtthat names a state (NV-02, or a bare002thatenrichment.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:
| Warning | Where | When |
|---|---|---|
no_location_columns | the 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_columns | GET /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_unlocated | GET /contact-lists/{id}, once status is succeeded | Some 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).
"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
| Value | For a NEW person | For a MATCHED person |
|---|---|---|
add_new_only default | Created | Fills only fields that are currently empty. Existing values are never overwritten. |
update_all | Created | Overwrites fields with your CSV values. An empty CSV cell is ignored, never written — this API cannot blank an existing value. |
skip_existing | Created | Nothing 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
| Value | Meaning |
|---|---|
external_id default | Match 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. |
email | Match on email address. |
custom:<name> since 2026-10-08 | Match 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:
{
"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 thatcustom:field. None is400 custom_matching_key_unusable(the message names the field). Two columns on one field are refused too: two spellings of its name as400 invalid_field_mappings(field_mappings), its name from one column and its key from another as400 custom_matching_key_unusable. The name follows the rules of anycustom:target (Field targets): case, spaces, hyphens and underscores are interchangeable, and a field’s key fromGET /field-schemaworks 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_unusableorrow_number_matching_key, before any row is imported. So does a file that has no column with the exact header yourfield_mappingsput on the key. - Send
field_mappingswith 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 likevoter_idoridis read asexternal_idinstead, and the job fails withcustom_matching_key_unusable; a header we cannot read at all fails it withmapping_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_strategythen 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 (
_categoryduplicate) and counts inrows.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_idstill follows The VAN id state for that column (van_ids: true, andid_stateorid_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.
{
"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}
/api/partner/v1/contact-lists/{id}120 / minuteThe 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.
{
"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
| status | Meaning | Terminal? |
|---|---|---|
queued | Accepted. 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 |
fetching | Downloading from your presigned URL. | no |
importing | Reading rows, matching, creating/updating contacts. | no |
enriching | Contacts are in; district/geo enrichment is running. | no |
succeeded | Done. | yes |
failed | Done, unsuccessfully. See error.code. | yes |
Row counts
| Key | Meaning |
|---|---|
total | Rows 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. |
processed | Rows read so far. |
created | New contacts created. |
updated | Existing contacts whose fields changed. |
skipped | Rows deliberately not applied (e.g. skip_existing matches, in-file duplicates). |
suppressed | Rows dropped because the person is on the organization’s suppression/opt-out list. |
failed | Rows 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_mappedistruewhen we resolved headers ourselves.source— host and object key only. We never echo your presigned URL or any part of its query string.source.encodingis the encoding we detected (utf-8,utf-8-sigfor 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);nullwhenever it is not waiting.error—nullunlessstatusisfailed. See Errors.warnings— the202’s warnings, then any recorded since:no_location_columnswhen we resolved your headers ourselves, andcontacts_unlocatedoncestatusissucceeded(Where your contacts are), androw_number_columns_skippedwhen we skipped columns of row numbers (Automatic header resolution), andblank_rows_skippedwhen rows of the file had no value in any column (details.blank_rows; they are skipped, counted in norowsnumber 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:
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-schemafor 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>"}:
"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>, orskip. - If you supply
field_mappingsat 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
skipfor the others).skipmay 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
| Target | Notes |
|---|---|
first_name | |
last_name | |
email | Also usable as matching_key. |
external_id | Your 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_address | Feeds district enrichment. |
city | |
state | Two-letter code. |
zip_code | Also feeds the single-CD ZIP district fallback. |
congressional_district | Pre-enriched value, if you have one. |
state_senate_district | |
state_house_district | |
manual_timezone | A 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_6 | Strict 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). |
skip | Ignore 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 header | Resolves to |
|---|---|
vanid, van_id, ngpvan_id, voter_id, voterid, external_id, id | external_id |
first_name, firstname, first, fname | first_name |
last_name, lastname, last, lname, surname | last_name |
email, email_address, e-mail | email |
phone, phone_number, cell, cell_phone, mobile | phone_1 |
home, home_phone | phone_2 |
work, work_phone | phone_3 |
address, address1, street, street_address, mailing_address | street_address |
city, mailing_city | city |
state, mailing_state | state |
zip, zipcode, zip_code, postal, postal_code | zip_code |
cd, congressional_district | congressional_district |
timezone, tz, time_zone | manual_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.
{
"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.
| HTTP | code | Meaning / fix |
|---|---|---|
| 401 | unauthorized | Token absent, malformed, revoked or expired. One message for all of them by design. |
| 403 | forbidden | The 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). |
| 403 | token_principal_not_permitted | The token’s user is a platform superuser. Superuser tokens are refused. |
| 403 | org_mismatch | You sent an X-Org-Idthat is not the token’s organization. Stop sending it. |
| 403 | organization_closed | The organization is closed. |
| 400 | invalid_source_url | Not an https URL on a bucket’s own s3[.region].amazonaws.com host, or credentials embedded in the URL. |
| 400 | not_a_presigned_url | The SigV4 query parameters are missing. Presign it. |
| 400 | source_url_expired | The URL has expired, or expires too soon to be useful. Presign for ≥15 minutes and post promptly. |
| 400 | invalid_field_mappings | Bad shape, unknown target, no phone target, duplicate target, or too many custom: targets. details names the column. |
| 400 | invalid_matching_key | Not external_id, email or a well-formed custom:<name> (1–64 characters, no control characters). |
| 400 | custom_matching_key_unusable | matching_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. |
| 400 | row_number_matching_key | matching_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. |
| 400 | custom_matching_key_disabled | Matching on a custom field is switched off on our side. Use external_id or email, or contact us. |
| 400 | invalid_merge_strategy | Not add_new_only, update_all or skip_existing. |
| 400 | van_ids_unconfirmed | field_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. |
| 400 | id_state_required | field_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. |
| 400 | id_state_ambiguous | Both id_state and id_state_column were sent. Send exactly one. |
| 400 | invalid_id_state | id_state is not a US state, DC or a territory (PR, GU, VI, AS, MP). |
| 400 | id_state_district_mismatch | id_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. |
| 400 | invalid_district_state | enrichment.district_state is not a US state or territory (a 2-letter code or a name). |
| 400 | outreach_not_accepting_contacts | The outreach is completed or archived. |
| 404 | outreach_not_found | No such outreach in your organization. |
| 404 | contact_list_not_found | No such job in your organization. |
| 409 | idempotency_key_reused | Same key, different body. Use a new key, or resend the identical body to replay. |
| 409 | import_queue_full | 3 imports running and 10 already waiting. details.retry_after_seconds and the Retry-After header tell you when to retry. |
| 422 | invalid_request | Schema violation — unknown key, wrong type, bad idempotency_key format. details.fields lists them. |
| 429 | rate_limited | Slow down; honor Retry-After. |
| 500 | internal_error | Our fault. Retry; if it persists, send us the request_id. |
Asynchronous errors
Reported as status: "failed", in error.code.
| code | Meaning / fix |
|---|---|
source_url_expired | The presign had expired by the time we fetched. Presign for ≥15 minutes and post the job immediately. |
source_url_forbidden | S3 returned SignatureDoesNotMatch or AccessDenied. Almost always (a) a query parameter was appended after signing, or (b) the URL was re-encoded. |
source_not_found | S3 returned 404 for that key. |
source_region_mismatch | The bucket is in a different region than the URL’s endpoint. Presign against the bucket’s own region. |
source_unreachable | We could not fetch it after retries. |
source_too_large | Over the 50 MB cap. Split the file. |
source_not_csv | The bytes are not a plain-text CSV (gzip, xlsx, PDF and HTML are detected and rejected). Upload an uncompressed .csv. |
csv_no_header | No usable header row. |
csv_no_rows | Header present, no data rows. |
csv_too_many_rows | Over the row cap. |
csv_too_many_columns | Over the column cap. |
mapping_unresolvable | Some columns could not be matched to a field. Send explicit field_mappings for them, or rename them. |
van_ids_unconfirmed | We 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_required | We 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_column | id_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_missing | No 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_conflict | No 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_key | A 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_unusable | The 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_disabled | Matching 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_imported | The 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_importing | The 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_url | The URL no longer passed validation when the import ran. Also a synchronous code — see above. |
not_a_presigned_url | The URL’s signature parameters were missing when the import ran. Also a synchronous code — see above. |
internal_error | Our 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 →
200with"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, notid_state_required. A new key with that body is refused. The same holds for a request accepted beforevan_ids(2026-10-07) that did not send it, and for a request on acustom:matching key: its retry returns its job even if a check on that key would refuse a new request now. matching_keyis part of the fingerprint, as it always was: the same key with anothermatching_keyis409 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_importedwhen 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.
# 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
doneimport time
import boto3, requests
url = boto3.client("s3", region_name="us-east-1").generate_presigned_url(
"get_object",
Params={"Bucket": "moveon-lists", "Key": "universes/ga-2026-09.csv"},
ExpiresIn=3600,
)
resp = requests.post(
"https://api.getmeteoric.io/api/partner/v1/contact-lists",
headers={"Authorization": f"Bearer {token}"},
json={
"idempotency_key": "moveon-ga-2026-09-03-01",
"source": {"type": "s3_presigned_url", "url": url}, # verbatim
"list_name": "GA Universe 2026-09",
"outreach_id": 4821,
"van_ids": True, # the vanid column holds VAN ids...
"id_state": "GA", # ...and they are Georgia ids
},
timeout=30,
)
resp.raise_for_status()
job_id = resp.json()["id"]
# Poll to completion
while True:
status = requests.get(
f"https://api.getmeteoric.io/api/partner/v1/contact-lists/{job_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
).json()
print(status["status"], status["rows"])
if status["status"] in ("succeeded", "failed"):
break
time.sleep(status.get("poll_after_seconds", 5))import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const base = "https://api.getmeteoric.io/api/partner/v1";
const token = process.env.METEORIC_TOKEN;
// 1) Presign the CSV (GET-signed, 1 hour)
const url = await getSignedUrl(
new S3Client({ region: "us-east-1" }),
new GetObjectCommand({ Bucket: "moveon-lists", Key: "universes/ga-2026-09.csv" }),
{ expiresIn: 3600 },
);
// 2) Create the import
const created = await fetch(base + "/contact-lists", {
method: "POST",
headers: {
Authorization: "Bearer " + token,
"Content-Type": "application/json",
},
body: JSON.stringify({
idempotency_key: "moveon-ga-2026-09-03-01",
source: { type: "s3_presigned_url", url }, // verbatim, never re-encoded
list_name: "GA Universe 2026-09",
outreach_id: 4821,
matching_key: "external_id",
merge_strategy: "add_new_only",
van_ids: true, // the vanid column holds VAN ids...
id_state: "GA", // ...and they are Georgia ids
}),
});
if (!created.ok) throw new Error(created.status + " " + (await created.text()));
const job = await created.json();
// 3) Poll to completion
let status = job;
while (status.status !== "succeeded" && status.status !== "failed") {
await new Promise((done) => setTimeout(done, (status.poll_after_seconds || 5) * 1000));
const res = await fetch(base + "/contact-lists/" + job.id, {
headers: { Authorization: "Bearer " + token },
});
status = await res.json();
console.log(status.status, status.rows);
}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
httpsonly, on the bucket’s own…s3[.<region>].amazonaws.comhost.- 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
- 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 answers403→source_url_forbidden. - 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. - 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. - We only ever issue a
GET. We do notHEADyour object to check reachability: aHEADagainst a GET-signed URL always403s, so the check would be pure noise. Sign forGET, and expect exactly one fetch — the file is copied on first read, so retries never touch your URL again. - 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_urlevery 5 seconds untilsucceededorfailed.
§10
Reconciliation — GET /contact-lists
/api/partner/v1/contact-lists30 / minuteRecent jobs, newest first. The endpoint you reconcile a night of pushes against.
GET /api/partner/v1/contact-lists?limit=25&offset=0Newest 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.
{
"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
/api/partner/v1/field-schema60 / minuteThe 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).
{
"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
/api/partner/v1/whoami60 / minuteWhich 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.
{
"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
Initial Contact Lists v1 contract published.
Self-serve API keys (Settings → API keys); timestamps and response examples corrected against a live run.
GET /field-schema lists your organization's existing custom fields; custom:<name> matches case-insensitively against names and keys.
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.
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.
enrichment.district_state: the state your file's bare congressional-district numbers belong to (VAN writes NV-02 as 002).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).