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

MethodPathPurpose
POST/contact-listsCreate a bulk-load job from a presigned S3 CSV URL
GET/contact-lists/{id}Status of one job
GET/contact-listsRecent jobs, paged (reconciliation)
GET/field-schemaMachine-readable field vocabulary
GET/whoamiWhich organization/user this key acts as

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) or email.
merge_strategyOptionaladd_new_only (default), update_all, skip_existing.
enrichment.*OptionalDefaults as shown in the example.

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"},
    "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

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). The right choice for voter files.
emailMatch 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.

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
}

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

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.
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 could not read — this is where strict-NANP phone rejections land.

Other fields

  • field_mappings / auto_mapped — the mappings actually used. auto_mapped is true when we resolved headers ourselves.
  • sourcehost 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. 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.
  • errornull unless status is failed. 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:

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.
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_timezonee.g. America/Chicago. Only set when you know better than our area-code/ZIP inference.
phone_1phone_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.
skipIgnore 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 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 role does not permit contact import.
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 or email.
400invalid_merge_strategyNot add_new_only, update_all or skip_existing.
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.
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 body200 with "replayed": trueand the original job’s id. Safe to retry after a timeout.
  • Same key + different body409 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.

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

# 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 403source_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 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.

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.

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

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