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. 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 |
|---|---|---|
| POST | /contact-lists | Create a bulk-load job from a presigned S3 CSV URL |
| GET | /contact-lists/{id} | Status of one job |
| GET | /contact-lists | Recent jobs, paged (reconciliation) |
| GET | /field-schema | Machine-readable field vocabulary |
| GET | /whoami | Which organization/user this key acts as |
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) or email. |
merge_strategy | Optional | add_new_only (default), update_all, skip_existing. |
enrichment.* | Optional | Defaults as shown in the example. |
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"},
"support": {"field": "custom:support_level"},
"notes": {"field": "skip"}
},
"matching_key": "external_id",
"merge_strategy": "add_new_only",
"enrichment": {
"enabled": false,
"include_congressional": true,
"include_state_leg": true,
"zip_district_fallback": false
}
}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). The right choice for voter files. |
email | Match on email address. |
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.)
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.
{
"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
}§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
}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. |
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 could not read — this is where strict-NANP phone rejections land. |
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. 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.
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. |
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 | e.g. America/Chicago. Only set when you know better than our area-code/ZIP inference. |
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. |
skip | Ignore this column. |
There is no target for internal-only or superuser-only fields; they are not reachable through this API.
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 VAN export usually needs no mappings at all:
| 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 role does not permit contact import. |
| 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 or email. |
| 400 | invalid_merge_strategy | Not add_new_only, update_all or skip_existing. |
| 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. |
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.
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.
§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"
}')"
# 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,
},
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",
}),
});
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 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.
{
"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.
Contract source new-dialer-2025/docs/public-api/contact-lists-v1.md (reviewed 2026-09-18).