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

# Contacts and campaign enrollment

> Push leads from your own systems and have them called by an always-open campaign

When a lead arrives in your own system (a form, a CRM, a signup), send it to
Centillion as a contact and enroll it in a campaign. The campaign calls it on
its next run, outside the campaign's quiet hours and at its rate. These
endpoints use the workspace keys and limits described in the
[Developer API](https://cntillion.com/docs/api-reference/developer-api.md), and both writes need a
**Full access** key.

| Method | Path | Behavior |
| - | - | - |
| PUT | `/agents/{agentId}/contacts` | Create or update a contact, with its call consent |
| POST | `/campaigns/{campaignId}/enrollments` | Enroll a contact in an always-open campaign |
| GET | `/campaigns/{campaignId}/enrollments/{enrollmentId}` | An enrollment's status and outcome |

## Keep a campaign open

A campaign normally freezes its audience at launch and finishes once everyone
in it has been contacted. Turn on **Keep open for new contacts** under
**Schedule and sending rules** to keep it running after its audience is done.
Contacts you enroll join it while it runs, and it ends only when you stop it.
You can launch an always-open campaign with no contacts selected and fill it
through the API alone.

Enrollment works while the campaign is scheduled, running, or paused. A paused
campaign holds new contacts until you resume it.

## Create or update a contact

```bash theme={null}
curl -X PUT 'https://app.cntillion.com/api/v2/agents/YOUR_AGENT_ID/contacts' \
  -H "Authorization: Bearer $CENTILLION_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: lead-58213' \
  -d '{
    "name": "Jordan Rivera",
    "phone": "+16505550100",
    "email": "jordan@example.com",
    "timeZone": "America/Los_Angeles",
    "attributes": { "plan_interest": "pro", "requested_amount": 25000 },
    "consent": {
      "source": "web_form",
      "capturedAt": "2026-10-16T14:02:11Z",
      "text": "I agree to receive calls, including automated and AI calls, about my request at the number provided.",
      "reference": "form-submission-58213, consent text v3",
      "scope": "Calls about the financing request submitted on the website"
    }
  }'
```

The contact is matched by phone number first. When no contact has that number,
it is matched by email, and that contact takes the new phone number. A match is
updated in place; otherwise a contact is created. A phone that belongs to one
contact and an email that belongs to another is refused (`CONTACT_CONFLICT`). The response is
`201` for a new contact and `200` for an updated one, with the contact in
`data` and `data.callConsent` saying whether the number has a call consent on
record.

* `phone` must be in E.164 format, with its country code, such as
  `+16505550100`. Send a phone or an email, or both.
* `timeZone` is an IANA time zone. Calls keep to quiet hours in it.
* `attributes` are custom fields, by name: lowercase letters, digits and
  underscores, starting with a letter. A name the agent already has must match
  its type (text, number, `true`/`false`, or a `YYYY-MM-DD` date). A new name
  creates the attribute, typed from the value, so it shows in Contacts and the
  agent can use it on calls. `null` clears a value. Names starting with
  `voice_` are reserved for what calls record.
* `consent` records the person's agreement to be called, the same record as
  **Contacts → Call permissions**. `source` is `web_form`, `written`, `verbal`
  or `other`; `capturedAt` is when the person agreed; `text` is the exact
  wording they agreed to; `reference` says where the evidence is kept, such as
  a form submission ID and the version of the consent text; `scope` says what
  the consent covers. Sending the same consent again does not add a second
  record, and does not restore a consent that was withdrawn: only new evidence
  does.

## Enroll a contact

```bash theme={null}
curl 'https://app.cntillion.com/api/v2/campaigns/YOUR_CAMPAIGN_ID/enrollments' \
  -H "Authorization: Bearer $CENTILLION_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: lead-58213-enroll' \
  -d '{
    "contactId": "CONTACT_ID",
    "variables": { "requested_amount": 25000, "preferred_product": "line of credit" }
  }'
```

Send `contactId` for a contact you already have, or `contact` with the same
fields as the contact request above to create or update it and enroll it in one
call. The contact must belong to the campaign's agent.

`variables` are written onto the contact as attributes, under the same naming
and type rules, so the agent's call can use them. They are written on every
call, so enrolling a contact again with new values updates the contact, while
the enrollment keeps the variables it was created with.

The response is `201` with the new enrollment in `data`, or `200` with the
existing one when the contact is already in the campaign. Read it again with
`GET /campaigns/{campaignId}/enrollments/{enrollmentId}`. An enrollment has:

* `id`, `campaignId`, `contactId`, `createdAt`, `updatedAt`
* `status`: `queued`, `sending`, `sent`, `waiting_for_voice`, `skipped`,
  `failed` or `needs_review`
* `nextAttemptAt`: when it is due, for example after quiet hours
* `attempts`, `sentAt`, `voiceCallId`, `repliedAt`, `lastError`
* `outcome`: `reactivated`, `reason_collected`, `no_answer`, `opted_out`, or
  `null` until there is one, with `outcomeAt` and `outcomeDetail`
* `variables` as sent

Enrolling does not check consent records: having the person's consent before
you call is your responsibility, and a `consent` sent with the contact is kept
as your record of it. Just before it dials, every call checks the do-not-call
list, and whether an earlier call on the number reached someone other than the
contact.

## Retries

Both writes need an `Idempotency-Key` header of 1 to 128 characters, unique to
the change you mean to make. Retrying with the same key and the same body within 24 hours returns the
first answer, with its status, and `Idempotent-Replayed: true`, and changes
nothing. Reusing a key with a different body returns
`409 IDEMPOTENCY_KEY_REUSED`. A request that failed changed nothing, so retrying
it with the same key runs it again. The workspace, the campaign and the plan are
checked before the stored answer is read, so a retry after the campaign was
deleted or the plan lost campaigns gets that error instead.

## Errors

| Code | Status | Meaning |
| - | - | - |
| `IDEMPOTENCY_KEY_REQUIRED` | 400 | The `Idempotency-Key` header is missing, blank, or longer than 128 characters |
| `INVALID_REQUEST` | 400 | A field is missing or malformed; the message names it |
| `INVALID_PHONE` | 400 | `phone` is not in E.164 format |
| `INVALID_ATTRIBUTE` | 400 | A value does not match its attribute's type, or the attribute is archived |
| `INVALID_CONSENT` | 400 | `consent.capturedAt` is in the future |
| `NO_PHONE` | 400 | The contact has no phone number to call |
| `PLAN_REQUIRED` | 403 | The workspace plan does not include campaigns |
| `AGENT_NOT_FOUND`, `CAMPAIGN_NOT_FOUND`, `CONTACT_NOT_FOUND`, `ENROLLMENT_NOT_FOUND` | 404 | Not found in this workspace |
| `CAMPAIGN_NOT_LAUNCHED` | 409 | The campaign is still a draft |
| `CAMPAIGN_CLOSED` | 409 | The campaign has finished, or is not kept open for new contacts |
| `CONTACT_CONFLICT` | 409 | The phone and the email belong to two different contacts |
| `NUMBER_ALREADY_ENROLLED` | 409 | Another contact with the same phone number is already in the campaign; each number is called once per campaign |
| `IDEMPOTENCY_KEY_REUSED` | 409 | As above |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.