> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.vodex.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Contacts

> People, audiences and campaigns — calling a list you keep here instead of one you paste in

Everything so far in this API is **contact-stateless**: `POST /calls` takes a
number, `POST /batches` takes rows. That is deliberate, and it stays true. The
contacts layer sits beside it for the case where you would rather Vodex kept the
people — so a campaign can say *"everyone in Ohio with a balance over \$500"*
instead of a pasted CSV, and so the report that comes back joins to a person
rather than to a phone number.

<Note>
  A campaign is **not** a second dialer. Launching one creates an ordinary
  [batch](/api-reference/batches/create-a-batch) — same runner, same number
  rotation, same calling-window logic. What a campaign adds is *who*.
</Note>

## The shape of it

<Steps>
  <Step title="Contacts">
    People, with any number of phone and email channels. Anything
    industry-specific is a **custom attribute**, never a column.
  </Step>

  <Step title="Audiences">
    A saved **filter**, not a saved list — so it means the same thing next
    month, when the people who match it are different.
  </Step>

  <Step title="Campaigns">
    An audience plus an assistant. Creating one dials nobody; launching turns it
    into a batch.
  </Step>

  <Step title="Do not contact">
    Two separate things: a decision about a **person**, and a suppression list of
    **addresses**.
  </Step>
</Steps>

## Getting people in

Create them one at a time:

```bash theme={null}
curl -X POST https://apiv2.vodex.ai/v1/contacts/accounts \
  -H "Authorization: Bearer $VODEX_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Jordan",
    "lastName": "Reyes",
    "externalRef": "crm-88213",
    "channels": [{ "kind": "phone", "label": "mobile", "value": "+14155551234" }]
  }'
```

Or [import a CSV](/api-reference/contacts/import-contacts-from-a-csv), which is
how most tenants start:

```bash theme={null}
curl -X POST https://apiv2.vodex.ai/v1/contacts/imports \
  -H "Authorization: Bearer $VODEX_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"csv\": $(jq -Rs . < contacts.csv), \"filename\": \"contacts.csv\" }"
```

The import is **synchronous and transactional** — one request, one receipt, no
job to poll. Headers auto-map, and an unrecognised column becomes a custom
attribute rather than an error or a silent drop, so a CRM export lands with no
mapping UI. Rows upsert on `externalRef`, else `accountNumber`, so re-running
the same export reports updates instead of duplicates, and channels **merge**
rather than replace: a second file listing only a mobile will not delete the
work number the first one brought. Rejections carry the **file** line number —
the one your spreadsheet shows — up to 200 of them, and the ceiling is 50,000
rows.

[`GET /contacts/imports/template`](/api-reference/contacts/a-csv-template-with-the-recognised-headers)
returns the headers the importer maps without configuration.

Two things are rejected rather than guessed:

|                                   |                                                                                                                                                                                             |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A number with no country code** | The guess would be "the country the server runs in", and a wrong guess dials a stranger abroad. Send E.164.                                                                                 |
| **A full nine-digit SSN**         | `ssnLast4` takes four digits and a full one is a `400`. Vodex does not store a full SSN, and quietly keeping four digits of a value you believe was stored in full leaves you believing it. |

## Audiences are filters

```bash theme={null}
curl -X POST https://apiv2.vodex.ai/v1/contacts/audiences \
  -H "Authorization: Bearer $VODEX_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Ohio, balance over 500",
    "filter": {
      "op": "and",
      "rules": [
        { "field": "state", "cmp": "eq", "value": "OH" },
        { "field": "attr:balance", "cmp": "gt", "value": "500" }
      ]
    }
  }'
```

Stored as a **filter tree, never a materialised id list**. A campaign launched
next Tuesday should reach the people who match next Tuesday, including the ones
imported tomorrow; a frozen list would make every recurring campaign a snapshot
of the day it was written.
[Members](/api-reference/audiences/who-is-in-an-audience-right-now) are
therefore evaluated on read, and two calls a week apart can legitimately differ.

Fields are whitelisted — `attr:<key>` reaches a custom attribute — and
[the whitelist is published](/api-reference/audiences/the-fields-a-filter-may-reach-and-their-comparators)
with the comparators each type admits, because a client that guesses field names
gets a `400`. The filter is validated when you save it, not only when a campaign
runs, so a typo fails now rather than on the morning of the call.

[Preview](/api-reference/audiences/count-and-sample-who-a-filter-matches) before
committing:

```json theme={null}
{ "total": 1204, "suppressed": 38, "accounts": [ … ] }
```

Exclusions are reported **separately, never subtracted**. "1,204 match, 38 are
suppressed" is the honest sentence; a single number that quietly already had the
exclusions removed cannot be reconciled against the member list the same filter
shows.

## Campaigns

```bash theme={null}
curl -X POST https://apiv2.vodex.ai/v1/contacts/campaigns \
  -H "Authorization: Bearer $VODEX_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "March outreach",
    "audienceId": "…",
    "assistantId": "3f9c…",
    "concurrency": 10,
    "window": { "start": "09:00", "end": "19:00" },
    "variables": { "firstName": "firstName", "balance": "attr:balance" }
  }'
```

`variables` maps each assistant `{{var}}` to a contact field or `attr:<key>`;
[the sources are listed](/api-reference/campaigns/what-a-prompt-variable-can-be-filled-from)
rather than guessed. A variable with no value for a contact makes that person a
`rejected` enrollment at launch — not a call with a blank in the prompt.

Then [launch](/api-reference/campaigns/turn-the-audience-into-calls), which is
the only call that dials anything:

```json theme={null}
{
  "batchId": "…",
  "enrolled": 1166,
  "suppressed": { "dnc_list": 31, "no_channel": 5, "bad_number": 2 }
}
```

Three things worth knowing about launch:

* **Every refusal is recorded as an enrollment with a reason** — `dnc_account`,
  `dnc_list`, `opted_out`, `no_channel`, `bad_number`, `rejected`. "Why was this
  person not called" is what a compliance review asks, and a batch row that was
  never created cannot answer it.
* **`tz` is injected on every row** from the contact's timezone. Calling people
  only during their own local hours is the entire point of having contacts.
* **Relaunch is idempotent** on `campaign:<id>:run:<n>`, so a double-clicked
  launch replays instead of dialling the audience twice.

Steer it from the campaign — `pause`, `resume`, `stop` — rather than opening its
batch. Those delegate to the same transition the batch routes use, and campaign
`status` is derived from the batch on every read, so the two can never disagree.

Set `blockSize` to dial the audience in blocks; nothing beyond the current block
runs until you
[ask for the next one](/api-reference/campaigns/dial-the-next-block).

<Warning>
  Once a campaign is live, `PATCH /contacts/campaigns/{id}` answers `409`. Use
  [the live route](/api-reference/campaigns/change-a-running-campaign-safely) —
  `concurrency`, `retry`, `window`, `startAt`, and nothing else. A scheduled
  campaign is `running` with a future `startAt`, so that is also how you
  reschedule one.
</Warning>

## Do not contact

There are **two** concepts here, and neither is derived from the other:

|                                                                                          |                                                                                                                                                                                                                                   |
| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [**The person**](/api-reference/do-not-contact/mark-a-person-do-not-contact-or-clear-it) | A decision — "never call Jordan". Setting it also writes suppression entries for that person's channels with `source: account`.                                                                                                   |
| [**The address**](/api-reference/do-not-contact/the-suppression-list)                    | A number or email that must never be dialled. Most of what belongs here names an address **no contact exists for**: a scrubbed regulator file, an opt-out from a number nobody imported, a wrong number that keeps being reached. |

<Warning>
  Clearing a person's flag removes **only the entries that flag created**. An
  address suppressed by uploading a regulator's list survives — the alternative is
  one person toggling a switch and silently resuming calls the tenant is obliged
  to stop.
</Warning>

[Upload a list](/api-reference/do-not-contact/upload-a-suppression-list) as CSV,
and [ask about addresses](/api-reference/do-not-contact/ask-whether-addresses-are-suppressed)
before you dial:

```bash theme={null}
curl -X POST https://apiv2.vodex.ai/v1/contacts/dnc/check \
  -H "Authorization: Bearer $VODEX_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "values": ["+14155551234", "(415) 555-9876"] }'
```

Values are normalised by the same code the channel path uses before they are
compared — on the way in, on upload, and here. If any two of those disagreed by
so much as a space, a number on the list would still be dialled and nothing
anywhere would report an error.

`check` is a read, so a **viewer** may call it; `POST` only because it takes a
list in the body.

## Joining back to the call

Launch sets each row's `contactRef` to the contact's id, which is the same field
you would set by hand on
[`POST /calls`](/api-reference/calls/place-an-outbound-call) — so it is echoed
on the `interaction.completed` [webhook](/webhooks) exactly as it always was.
Nothing about the report changes because a call came from a campaign.

That one field is also the whole of the join:
[the timeline](/api-reference/contacts/everything-that-has-happened-to-this-contact)
matches calls on it, with no projection to maintain and no events to replay —
and it works retroactively, for calls placed before the contact record existed.
Enrollments appear there too, including the ones that were never called, each
with its reason in words.

The [compliance view](/api-reference/contacts/consent-local-time-and-attempt-counts)
gathers what a reviewer wants on one screen: consent basis and when it was
captured, the contact's local time right now, the tenant's calling window, and
attempt counts for the last seven days.

<Note>
  Those counts **gate nothing** — the response says `enforced: false`, because a
  number that looks like a limit but is not one is worse than no number at all.
  Attempt limits are the retry policy on the batch.
</Note>

## Next

<CardGroup cols={2}>
  <Card title="Batch dialing" icon="list" href="/api-reference/batches/create-a-batch">
    What a launched campaign becomes. Retry policy, number rotation, calling
    windows.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/webhooks">
    The report that comes back, with your `contactRef` on it.
  </Card>

  <Card title="Import contacts" icon="upload" href="/api-reference/contacts/import-contacts-from-a-csv">
    Headers, upsert keys, and what a rejected row tells you.
  </Card>

  <Card title="Launch a campaign" icon="phone" href="/api-reference/campaigns/turn-the-audience-into-calls">
    The one call that dials, and everything it refuses to dial.
  </Card>
</CardGroup>
