> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.jelliu.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.jelliu.co/_mcp/server.

# Contacts

A **contact** is a person an agent can reach: a phone number to dial, a WhatsApp number to message, an email address to write to, plus a name and free-form `metadata` the agent can use during the conversation. Contacts live inside a [campaign](/resources/campaigns), which is what tells the dialer and the text channels what to say. Contacts that do not belong to any campaign you run are kept in a system campaign so they can still be messaged, called and synced.

On top of the contact rows sits the workspace **suppression list**: numbers that must never be contacted again, whether the person asked on a call, wrote `STOP` on WhatsApp, or came from a national do-not-call registry you imported.

## How it works

```mermaid
flowchart LR
  A["POST /api/campaigns/{id}/contacts"] --> R[(Contact rows)]
  B["POST .../contacts/bulk<br />up to 5,000"] --> R
  C["CSV: upload-preview<br />then upload-confirm"] --> R
  D["POST /api/contacts<br />(no campaign)"] --> M["System campaign<br />Manual Conversations"] --> R
  R --> G{"Compliance gate<br />before every dial"}
  S[(Suppression list)] --> G
  G -->|allowed| O["Call, WhatsApp, email"]
  G -->|blocked| X["Not contacted"]
  O -->|"opt-out heard or written"| S
  O -->|"opt-out"| DNC["Contact status: dnc"]
```

1. **Every contact belongs to a campaign.** Campaign contacts are created under `/api/campaigns/{campaignId}/contacts`. `POST /api/contacts` saves a contact for the workspace without choosing a campaign: it is stored in the workspace's system campaign named `Manual Conversations`, which is created on demand and does not count as an active campaign on your plan.
2. **One row per phone number per campaign.** Within a campaign, the phone number is the uniqueness key. Adding a number that is already there does not create a duplicate (see [Deduplication](#deduplication)).
3. **Contacts move through statuses** as the dialer and the text channels work: `pending` → `called` → `converted`, or into `failed`, `invalid` or `dnc`.
4. **Opt-outs are durable.** When someone asks not to be contacted, their number goes onto the suppression list, which is keyed by number and not by contact row. Deleting and re-importing the contact, or adding the same number to another campaign, does not make it reachable again.

## Object

### Campaign contact

Returned by `POST /api/campaigns/{campaignId}/contacts`, `POST /api/contacts`, and (with fewer fields) by `GET /api/campaigns/{campaignId}/contacts`. Fields are `snake_case`; timestamps are ISO 8601 strings.

| Field                   | Type               | Nullable | Description                                                                                                                                                                                        |
| ----------------------- | ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                    | string (uuid)      | No       | Contact ID.                                                                                                                                                                                        |
| `tenant_id`             | string (uuid)      | No       | Your workspace ID.                                                                                                                                                                                 |
| `campaign_id`           | string (uuid)      | Yes      | The campaign the contact belongs to. For contacts created through `POST /api/contacts`, the ID of the `Manual Conversations` system campaign.                                                      |
| `phone_number`          | string             | No       | The dial target and the uniqueness key. An E.164 number, or a synthetic key starting with `em:` for contacts created with only an email address (see [Email-only contacts](#email-only-contacts)). |
| `email`                 | string             | Yes      | Email address.                                                                                                                                                                                     |
| `whatsapp_number`       | string             | Yes      | WhatsApp number in E.164 format.                                                                                                                                                                   |
| `name`                  | string             | Yes      | Display name.                                                                                                                                                                                      |
| `status`                | string             | No       | One of `pending`, `called`, `converted`, `failed`, `dnc`, `invalid`. See [Statuses](#statuses).                                                                                                    |
| `call_attempts`         | integer            | No       | Number of call attempts made to this contact.                                                                                                                                                      |
| `last_called_at`        | string (date-time) | Yes      | When the contact was last called.                                                                                                                                                                  |
| `created_at`            | string (date-time) | No       | When the row was created.                                                                                                                                                                          |
| `updated_at`            | string (date-time) | No       | When the row last changed.                                                                                                                                                                         |
| `deleted_at`            | string (date-time) | Yes      | Always `null` in responses: deleted contacts are not returned.                                                                                                                                     |
| `metadata`              | object             | Yes      | Custom fields. Create responses only; not included in the list.                                                                                                                                    |
| `crm_external_id`       | string             | Yes      | The record ID in your CRM, if you supplied one. Create responses only.                                                                                                                             |
| `crm_provider`          | string             | Yes      | The CRM the external ID belongs to. Create responses only.                                                                                                                                         |
| `sync_status`           | string             | Yes      | State of the push to a connected CRM. Create responses only.                                                                                                                                       |
| `last_synced_to_crm_at` | string (date-time) | Yes      | Last successful CRM push. Create responses only.                                                                                                                                                   |
| `sync_error`            | string             | Yes      | Last CRM push error. Create responses only.                                                                                                                                                        |
| `opted_out_at`          | string (date-time) | Yes      | When the person opted out on a text channel. Create responses only.                                                                                                                                |
| `opted_out_reason`      | string             | Yes      | Why, for example `whatsapp_keyword:baja` or `email_unsubscribe:...`. Create responses only.                                                                                                        |

### Workspace contact (CRM view)

`GET /api/contacts` returns a different, read-only shape: one row per **distinct phone number** across all campaigns, so the same person in three campaigns appears once with `campaign_count: 3`.

| Field             | Type               | Nullable | Description                                                                             |
| ----------------- | ------------------ | -------- | --------------------------------------------------------------------------------------- |
| `id`              | string (uuid)      | No       | The ID of one of the underlying contact rows for this number.                           |
| `phone_number`    | string             | No       | The number that groups the rows.                                                        |
| `name`            | string             | Yes      | A name found on one of the rows.                                                        |
| `email`           | string             | Yes      | An email found on one of the rows.                                                      |
| `campaign_count`  | integer            | No       | Number of distinct campaigns the number appears in.                                     |
| `last_contact_at` | string (date-time) | No       | Most recent call to the number, or the most recent `created_at` if it was never called. |
| `is_dnc`          | boolean            | No       | `true` if the number has status `dnc` in **any** campaign.                              |

When several rows share a number with different names or emails, `name` and `email` are aggregated, not "most recent". Do not use this view to decide which email is current; read the campaign contact instead.

### Statuses

| Status      | Meaning                                                                                             | Set by                                                 |
| ----------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `pending`   | Waiting to be contacted. Every new or reactivated contact starts here.                              | Create, bulk and CSV import.                           |
| `called`    | Picked up by the dialer, or called without reaching a success outcome.                              | The dialer and post-call processing.                   |
| `converted` | The interaction reached a success outcome. Never regresses to another status except `dnc`.          | Post-call processing and conversions on text channels. |
| `failed`    | The contact could not be dialed, for example when no window in the campaign schedule is left.       | The dialer.                                            |
| `invalid`   | The contact was never dialable, for example it has no E.164 phone number. Does not consume retries. | The dialer and campaign activation.                    |
| `dnc`       | The person asked not to be contacted. Outranks every other status.                                  | Opt-out detection on calls, WhatsApp and email.        |

A campaign cannot complete while any of its contacts is still `pending`.

## Creating contacts

### Request body

The same body is used by `POST /api/campaigns/{campaignId}/contacts`, `POST /api/contacts` and each item of the `contacts` array in `/bulk`.

**`phoneNumber`** `string`

E.164 format: `+`, a non-zero country digit, up to 15 digits in total, for example `+573001234567`. No spaces or dashes. Stored exactly as sent.

---

**`email`** `string`

A valid email address, up to 320 characters.

---

**`whatsappNumber`** `string`

E.164 format, same rules as `phoneNumber`.

---

**`name`** `string`

Up to 200 characters.

---

**`metadata`** `object`

Custom fields as string values. Up to 20 keys; keys may contain only letters, digits, `_` and `-`; each value up to 500 characters. Metadata is available to the agent during the conversation.

---

**`crmExternalId`** `string`

The record ID in your CRM, up to 500 characters.

---

**`crmProvider`** `string`

The CRM that owns `crmExternalId`, up to 50 characters.

---

At least one of `phoneNumber`, `email` or `whatsappNumber` is required. Otherwise the request fails with `400 VALIDATION_FAILED` and the message `At least one contact method is required (phoneNumber, email, or whatsappNumber)` in `details`.

Metadata values are scanned for card and national ID numbers before they are stored, on every path including CSV import. Any standalone run of 13 to 19 digits is replaced with `[REDACTED-CARD]`, and SSN-shaped values (`123-45-6789`, or a standalone 9-digit number) with `[REDACTED-SSN]`. Order numbers or customer IDs of those lengths are redacted too, so prefix them with letters (for example `ORD-123456789`) if you need them intact.

### Normalization

| Input                      | JSON endpoints                          | CSV import                                                                     |
| -------------------------- | --------------------------------------- | ------------------------------------------------------------------------------ |
| Phone and WhatsApp numbers | Must already be E.164. Not reformatted. | Spaces, dashes and parentheses are removed, and a `+` is prepended if missing. |
| Email                      | Stored as sent, including capitals.     | Lower-cased.                                                                   |
| Name and metadata          | Stored as sent.                         | Stored as sent, trimmed.                                                       |

CSV import does **not** know your country. A local number such as `3001234567` becomes `+3001234567`, which is a valid-looking E.164 number with the wrong country code, and the preview counts it as valid. Always include the country code in the file (`573001234567` or `+57 300 123 4567`).

### Deduplication

* **Same phone number, same campaign.** No second row is created. The existing contact is returned with `201`, and any of `email`, `whatsappNumber` or `name` you sent are written **only into fields that are empty** on the existing row. A value that is already stored is never overwritten, and `metadata` is not merged.
* **Same phone number, different campaign.** A separate row is created. Each row counts toward your plan's contact limit.
* **Previously deleted contact.** The row is restored with status `pending` instead of creating a new one.
* **Bulk and CSV.** Duplicates inside the request are dropped (the first occurrence wins), and numbers already in the campaign are skipped silently. They are not updated and not counted in `imported`.

#### Email-only contacts

A contact with only an `email` still needs a `phone_number`, so Jelliu stores a synthetic key such as `em:9c1b2f4e7a3d5c60` derived from the address. This key is never dialed; the dialer marks such a contact `invalid` on a voice campaign.

When you create a single contact with **only** an email, Jelliu first looks for an existing contact in the whole workspace with the same address, ignoring case. If it finds one, that contact is returned (with its name filled in if it had none), **even if it belongs to a different campaign** than the one in the URL. Check `campaign_id` in the response if the campaign matters to you.

## Common tasks

#### Add a contact to a campaign

Requires a `write` key. The campaign must exist and must not be `completed` or `archived`.

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+573001234567",
    "name": "Ana Gómez",
    "email": "ana.gomez@example.com",
    "metadata": {
      "company": "Transportes Andinos",
      "plan_interest": "growth"
    },
    "crmExternalId": "hs-8812731",
    "crmProvider": "hubspot"
  }'
```

**`Node.js`**

```javascript title="Node.js"
const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';

const res = await fetch(`https://api.jelliu.co/api/campaigns/${campaignId}/contacts`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    phoneNumber: '+573001234567',
    name: 'Ana Gómez',
    email: 'ana.gomez@example.com',
    metadata: { company: 'Transportes Andinos', plan_interest: 'growth' },
    crmExternalId: 'hs-8812731',
    crmProvider: 'hubspot',
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);

console.log(body.data.id, body.data.status); // "pending"
```

**`Python`**

```python title="Python"
import os
import requests

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"

res = requests.post(
    f"https://api.jelliu.co/api/campaigns/{campaign_id}/contacts",
    json={
        "phoneNumber": "+573001234567",
        "name": "Ana Gómez",
        "email": "ana.gomez@example.com",
        "metadata": {"company": "Transportes Andinos", "plan_interest": "growth"},
        "crmExternalId": "hs-8812731",
        "crmProvider": "hubspot",
    },
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}")

print(body["data"]["id"], body["data"]["status"])  # "pending"
```

Response `201 Created`:

```json
{
  "data": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "tenant_id": "4d7e9a10-2b3c-4d5e-8f60-718293a4b5c6",
    "campaign_id": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
    "phone_number": "+573001234567",
    "name": "Ana Gómez",
    "metadata": { "company": "Transportes Andinos", "plan_interest": "growth" },
    "call_attempts": 0,
    "last_called_at": null,
    "status": "pending",
    "email": "ana.gomez@example.com",
    "whatsapp_number": null,
    "crm_external_id": "hs-8812731",
    "crm_provider": "hubspot",
    "sync_status": "none",
    "last_synced_to_crm_at": null,
    "sync_error": null,
    "opted_out_at": null,
    "opted_out_reason": null,
    "created_at": "2026-09-14T15:42:07.318Z",
    "updated_at": "2026-09-14T15:42:07.318Z",
    "deleted_at": null
  }
}
```

The status is `201` whether the contact was created or already existed. Compare `created_at` with the time of your request if you need to tell them apart.

#### Save a contact without a campaign

`POST /api/contacts` takes the same body and stores the contact in the workspace's `Manual Conversations` system campaign. Use it when you need a contact ID to message someone from the conversations inbox but have no campaign for them. The workspace needs at least one agent: without one the request fails with `422`.

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/contacts" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Carlos Pérez",
    "whatsappNumber": "+5215512345678",
    "email": "carlos.perez@example.com"
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/contacts', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Carlos Pérez',
    whatsappNumber: '+5215512345678',
    email: 'carlos.perez@example.com',
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}`);

console.log(body.data.id, body.data.campaign_id);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.post(
    "https://api.jelliu.co/api/contacts",
    json={
        "name": "Carlos Pérez",
        "whatsappNumber": "+5215512345678",
        "email": "carlos.perez@example.com",
    },
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}")

print(body["data"]["id"], body["data"]["campaign_id"])
```

The response is the same contact object as above, with `201 Created`. A contact with a WhatsApp number but no phone number uses the WhatsApp number as its `phone_number`.

#### Import contacts in bulk (JSON)

`POST /api/campaigns/{campaignId}/contacts/bulk` accepts 1 to 5,000 contacts per request. Keep the request body under **1 MB**, the API's JSON size limit; for large lists that usually means a few thousand contacts per call. Rows are inserted in one transaction, so a batch either lands completely or not at all.

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts/bulk" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      { "phoneNumber": "+573001234567", "name": "Ana Gómez" },
      { "phoneNumber": "+573109876543", "name": "Luis Rojas", "metadata": { "city": "Medellín" } },
      { "email": "marta.diaz@example.com", "name": "Marta Díaz" }
    ]
  }'
```

**`Node.js`**

```javascript title="Node.js"
const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const contacts = [
  { phoneNumber: '+573001234567', name: 'Ana Gómez' },
  { phoneNumber: '+573109876543', name: 'Luis Rojas', metadata: { city: 'Medellín' } },
  { email: 'marta.diaz@example.com', name: 'Marta Díaz' },
];

// 5 requests per minute on this path: send large chunks, not many small ones.
const CHUNK = 2000;
let imported = 0;
for (let i = 0; i < contacts.length; i += CHUNK) {
  const res = await fetch(`https://api.jelliu.co/api/campaigns/${campaignId}/contacts/bulk`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ contacts: contacts.slice(i, i + CHUNK) }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
  imported += body.data.imported;
}
console.log(`Imported ${imported} new contacts`);
```

**`Python`**

```python title="Python"
import os
import requests

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"
contacts = [
    {"phoneNumber": "+573001234567", "name": "Ana Gómez"},
    {"phoneNumber": "+573109876543", "name": "Luis Rojas", "metadata": {"city": "Medellín"}},
    {"email": "marta.diaz@example.com", "name": "Marta Díaz"},
]

res = requests.post(
    f"https://api.jelliu.co/api/campaigns/{campaign_id}/contacts/bulk",
    json={"contacts": contacts},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=60,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")

print("Imported", body["data"]["imported"])
```

Response `201 Created`:

```json
{
  "data": {
    "imported": 3
  }
}
```

`imported` counts only **new** rows. Contacts whose number is already in the campaign are skipped without error, so `imported: 0` is a valid answer for a list you already loaded. If one contact in the array fails validation, the whole request is rejected with `400`, and the messages appear under `details.fieldErrors.contacts`.

#### Import a CSV file

CSV import is a two-step flow. **Preview** parses the file and proposes a column mapping without importing anything. **Confirm** re-sends the same file with the mapping you accept, and imports it.

File requirements:

* Extension `.csv` **and** a `Content-Type` of `text/csv`, `application/csv` or `text/plain` on the file part. Set it explicitly: many HTTP clients send `.csv` files as `application/octet-stream`, which is rejected.
* Up to **10 MB**. The multipart field is named `file`.
* UTF-8 (with or without BOM) or UTF-16 with a BOM. Latin-1 or Windows-1252 files are rejected: in Excel, use **Save As → CSV UTF-8**.
* Comma-separated, first row is the header. Excel workbooks (`.xlsx`, `.xls`) and PDFs are rejected even if renamed.
* At most 500 columns.

`GET /api/campaigns/{campaignId}/contacts/upload-template` returns a starter file with the header `phone_number,name,email,whatsapp_number`.

**1. Preview.**

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts/upload-preview" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -F "file=@contacts.csv;type=text/csv"
```

**`Node.js`**

```javascript title="Node.js"
import { readFile } from 'node:fs/promises';

const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const bytes = await readFile('contacts.csv');

const form = new FormData();
// The Blob type matters: without it the file part is rejected.
form.append('file', new Blob([bytes], { type: 'text/csv' }), 'contacts.csv');

const res = await fetch(
  `https://api.jelliu.co/api/campaigns/${campaignId}/contacts/upload-preview`,
  { method: 'POST', headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` }, body: form },
);
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);

const { detectedMapping, stats, fileHash } = body.data;
console.log(detectedMapping, stats);
```

**`Python`**

```python title="Python"
import os
import requests

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"

with open("contacts.csv", "rb") as fh:
    res = requests.post(
        f"https://api.jelliu.co/api/campaigns/{campaign_id}/contacts/upload-preview",
        files={"file": ("contacts.csv", fh, "text/csv")},
        headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
        timeout=60,
    )
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")

preview = body["data"]
print(preview["detectedMapping"], preview["stats"])
```

Response `200 OK`:

```json
{
  "success": true,
  "data": {
    "headers": ["Nombre", "Teléfono", "Móvil", "Correo", "Empresa"],
    "previewRows": [
      { "Nombre": "Ana Gómez", "Teléfono": "'+57 300 123 4567", "Móvil": "'+57 310 987 6543", "Correo": "Ana.Gomez@example.com", "Empresa": "Transportes Andinos" }
    ],
    "detectedMapping": {
      "Nombre": "name",
      "Teléfono": "phoneNumber",
      "Móvil": "metadata",
      "Correo": "email",
      "Empresa": "metadata"
    },
    "stats": {
      "totalRows": 1250,
      "validRows": 1238,
      "invalidRows": 12,
      "duplicateRows": 4,
      "errors": [
        { "row": 17, "field": "Correo", "message": "Invalid email: \"ana@\"" }
      ]
    },
    "fileHash": "3b0f6c1e9a2d4b7c8e5f10a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6"
  }
}
```

* `previewRows` holds the first 10 rows. Values starting with `=`, `+`, `-` or `@` are shown with a leading `'` in the preview only; the stored values are not altered.
* `stats` covers at most the first 1,000 rows, and `errors` lists at most 100. `row` is the line number in the file, counting the header as row 1.
* `fileHash` is the SHA-256 of the file. You must send it back on confirm.

**Review the mapping.** Each header maps to one of `phoneNumber`, `name`, `email`, `whatsappNumber`, `metadata` or `ignore`. Detection looks for common English and Spanish words **anywhere** in the header (`phone`, `tel`, `celular`, `nombre`, `correo`, `whatsapp`, `wa`, and so on) and, failing that, at the values in the first 10 rows. Because it matches substrings, an unrelated header can be claimed: `Hotel` contains `tel` and `Software` contains `wa`. Each of `phoneNumber`, `whatsappNumber`, `email` and `name` is assigned to the **first** matching column only; a second phone column, such as `Móvil` above, is mapped to `metadata`. Unrecognized columns also become `metadata`. Correct the mapping before confirming.

**2. Confirm.** Send the same file again with these form fields:

| Field          | Required | Description                                                                                                                                                                                                                                                                                                                                                                                                             |
| -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `file`         | Yes      | The exact same bytes as the preview. Any change fails with `File changed between preview and confirm`.                                                                                                                                                                                                                                                                                                                  |
| `mapping`      | Yes      | JSON object of `header` to target field, as a string.                                                                                                                                                                                                                                                                                                                                                                   |
| `fileHash`     | Yes      | The `fileHash` from the preview: 64 hexadecimal characters.                                                                                                                                                                                                                                                                                                                                                             |
| `metadataKeys` | No       | JSON object renaming `metadata` columns, as a string. For example `{"Empresa": "company"}` stores the column under `metadata.company` instead of `metadata.Empresa`.                                                                                                                                                                                                                                                    |
| `consent`      | No       | JSON object `{"source": "...", "evidence": "..."}`, as a string. The consent basis for this list, recorded for every imported contact. `source` is 2 to 80 characters (for example `web_form`, `contract`, `double_opt_in`) and `evidence` 3 to 500 characters (a URL, an export ID, a contract reference). Both are required when `consent` is present. Omit it to record the import as having no evidence of consent. |

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts/upload-confirm" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -F "file=@contacts.csv;type=text/csv" \
  -F 'mapping={"Nombre":"name","Teléfono":"phoneNumber","Correo":"email","Empresa":"metadata"}' \
  -F 'metadataKeys={"Empresa":"company"}' \
  -F "fileHash=3b0f6c1e9a2d4b7c8e5f10a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6" \
  -F 'consent={"source":"web_form","evidence":"https://example.com/webinar-signup"}'
```

**`Node.js`**

```javascript title="Node.js"
import { readFile } from 'node:fs/promises';

const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const bytes = await readFile('contacts.csv');

const form = new FormData();
form.append('file', new Blob([bytes], { type: 'text/csv' }), 'contacts.csv');
form.append('mapping', JSON.stringify({
  Nombre: 'name',
  'Teléfono': 'phoneNumber',
  Correo: 'email',
  Empresa: 'metadata',
}));
form.append('metadataKeys', JSON.stringify({ Empresa: 'company' }));
form.append('fileHash', fileHash); // from the preview response
form.append('consent', JSON.stringify({
  source: 'web_form',
  evidence: 'https://example.com/webinar-signup',
}));

const res = await fetch(
  `https://api.jelliu.co/api/campaigns/${campaignId}/contacts/upload-confirm`,
  { method: 'POST', headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` }, body: form },
);
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);

const { imported, skipped, errors } = body.data;
if (errors.length > 0) console.warn('Rows with problems:', errors);
console.log({ imported, skipped });
```

**`Python`**

```python title="Python"
import json
import os
import requests

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"

with open("contacts.csv", "rb") as fh:
    res = requests.post(
        f"https://api.jelliu.co/api/campaigns/{campaign_id}/contacts/upload-confirm",
        files={"file": ("contacts.csv", fh, "text/csv")},
        data={
            "mapping": json.dumps({
                "Nombre": "name",
                "Teléfono": "phoneNumber",
                "Correo": "email",
                "Empresa": "metadata",
            }),
            "metadataKeys": json.dumps({"Empresa": "company"}),
            "fileHash": preview["fileHash"],
            "consent": json.dumps({
                "source": "web_form",
                "evidence": "https://example.com/webinar-signup",
            }),
        },
        headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
        timeout=120,
    )
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")

result = body["data"]
if result["errors"]:
    print("Rows with problems:", result["errors"])
print(result["imported"], "imported,", result["skipped"], "skipped")
```

Response `200 OK`:

```json
{
  "success": true,
  "data": {
    "imported": 1234,
    "skipped": 12,
    "errors": [
      { "row": 17, "message": "Invalid email: ana@" },
      { "row": 88, "message": "No phone number, email, or WhatsApp number" }
    ]
  }
}
```

* Invalid rows are always skipped; they never fail the request. `skipped` is the number of rows that failed validation. Rows whose number is already in the campaign are neither `imported` nor `skipped`.
* `errors` is capped at 100 entries.

A `200` from `upload-confirm` does **not** mean every valid row was imported. Rows are written in chunks of 500, each in its own transaction, and a chunk that fails is reported inside `errors` instead of failing the request: for example `Failed to import rows 502-1001: Import of 500 contacts would exceed the plan limit (...)`. That is how the plan's contact limit, a campaign that was completed in the meantime, or a temporary database error show up here. Always read `errors`, and compare `imported` with the row count you expected.

Every form field is a string in `multipart/form-data`. Send `mapping`, `metadataKeys` and `consent` as JSON strings, and do not send the legacy `skipInvalid` field: as a string it fails validation, and invalid rows are skipped regardless. Text fields are limited to 1 MB and five levels of nesting.

#### List and search contacts

**In a campaign.** `GET /api/campaigns/{campaignId}/contacts` pages with `page` (default 1) and `limit` (1 to 100, default 50), oldest first, and filters by `status`. Use `status=dnc` to review opt-outs.

**`curl`**

```bash title="curl"
curl -sS "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts?status=dnc&page=1&limit=100" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const url = new URL(`https://api.jelliu.co/api/campaigns/${campaignId}/contacts`);
url.searchParams.set('status', 'dnc');
url.searchParams.set('limit', '100');

const res = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const { data } = await res.json();
console.log(`${data.contacts.length} of ${data.total}`);
```

**`Python`**

```python title="Python"
import os
import requests

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"

res = requests.get(
    f"https://api.jelliu.co/api/campaigns/{campaign_id}/contacts",
    params={"status": "dnc", "page": 1, "limit": 100},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
data = res.json()["data"]
print(len(data["contacts"]), "of", data["total"])
```

```json
{
  "data": {
    "contacts": [
      {
        "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "tenant_id": "4d7e9a10-2b3c-4d5e-8f60-718293a4b5c6",
        "campaign_id": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
        "phone_number": "+573001234567",
        "email": "ana.gomez@example.com",
        "whatsapp_number": null,
        "name": "Ana Gómez",
        "status": "dnc",
        "call_attempts": 1,
        "last_called_at": "2026-09-12T14:03:51.004Z",
        "created_at": "2026-09-10T09:15:22.871Z",
        "updated_at": "2026-09-12T14:05:10.442Z",
        "deleted_at": null
      }
    ],
    "total": 1,
    "page": 1,
    "limit": 100
  }
}
```

**Across the workspace.** `GET /api/contacts` returns the [CRM view](#workspace-contact-crm-view), newest first, with the same `page` and `limit`, plus `search` (1 to 200 characters), a case-insensitive match on phone number, name or email.

**`curl`**

```bash title="curl"
curl -sS "https://api.jelliu.co/api/contacts?search=gomez&limit=20" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const url = new URL('https://api.jelliu.co/api/contacts');
url.searchParams.set('search', 'gomez');
url.searchParams.set('limit', '20');

const res = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const { data } = await res.json();
for (const c of data.contacts) console.log(c.phone_number, c.name, c.is_dnc);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.get(
    "https://api.jelliu.co/api/contacts",
    params={"search": "gomez", "limit": 20},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
for c in res.json()["data"]["contacts"]:
    print(c["phone_number"], c["name"], c["is_dnc"])
```

```json
{
  "data": {
    "contacts": [
      {
        "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "phone_number": "+573001234567",
        "name": "Ana Gómez",
        "email": "ana.gomez@example.com",
        "campaign_count": 2,
        "last_contact_at": "2026-09-12T14:03:51.004Z",
        "is_dnc": true
      }
    ],
    "total": 1,
    "page": 1,
    "limit": 20
  }
}
```

`total` is the number of distinct phone numbers that match. Email-only contacts are grouped by their synthetic `em:` key, so each appears as its own row. List responses are cached briefly, so a status change made by a call or a message can take a moment to show; a `search` request on `/api/contacts` always reads live data. See [Pagination](/pagination).

#### Load a do-not-call registry into the suppression list

`POST /api/compliance/suppressions/import` adds numbers to the workspace suppression list. Requires a `full` key. Use it to load national registry exports, such as Colombia's Registro de Números Excluidos or the US National Do Not Call Registry, or your own list.

**`numbers`** `string[]` — required

1 to 200,000 numbers, each 5 to 32 characters. Spaces, dashes, dots, parentheses and non-breaking spaces are removed; what remains must be E.164 (with `+` and the country code) or the line is rejected. Local formats are **not** converted.

---

**`source`** `string` — required

One of `registry:rne_co` (Colombia), `registry:dnc_us` (United States), `registry:repep_mx` (Mexico), `registry:nmp_br` (Brazil), `customer_list`.

---

**`reason`** `string`

Up to 200 characters, stored on each new row, for example `RNE export 2026-09-01`. Defaults to `imported:` followed by the source.

---

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/compliance/suppressions/import" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "registry:rne_co",
    "reason": "RNE export 2026-09-01",
    "numbers": ["+573001112233", "+57 310 444 5566", "3205556677"]
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/compliance/suppressions/import', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    source: 'registry:rne_co',
    reason: 'RNE export 2026-09-01',
    numbers: ['+573001112233', '+57 310 444 5566', '3205556677'],
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}`);

console.log(body.data); // { suppressed, received, rejected, rejectedSample }
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.post(
    "https://api.jelliu.co/api/compliance/suppressions/import",
    json={
        "source": "registry:rne_co",
        "reason": "RNE export 2026-09-01",
        "numbers": ["+573001112233", "+57 310 444 5566", "3205556677"],
    },
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=60,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}")

print(body["data"])
```

Response `201 Created`:

```json
{
  "data": {
    "suppressed": 2,
    "received": 2,
    "rejected": 1,
    "rejectedSample": ["3205556677"]
  }
}
```

`received` and `suppressed` are the distinct valid numbers in the request, including numbers that were already suppressed. `rejectedSample` shows up to 10 rejected lines so you can spot a formatting problem. Importing is idempotent: a number already on the list keeps its original reason and date.

The 200,000-number limit is larger than the API's 1 MB JSON body limit allows in practice. Send big registry exports in pages of about 50,000 numbers.

**Check the list.** `GET /api/compliance/suppressions` returns counts per source and the registries whose most recent entry is older than 31 days:

```json
{
  "data": {
    "total": 48213,
    "bySource": [
      { "source": "registry:rne_co", "count": 48190, "lastImportedAt": "2026-08-01T12:00:03.114Z" },
      { "source": "whatsapp", "count": 17, "lastImportedAt": "2026-09-13T21:40:18.502Z" },
      { "source": "voice", "count": 6, "lastImportedAt": "2026-09-11T16:22:45.090Z" }
    ],
    "stale": [
      { "source": "registry:rne_co", "ageDays": 44 }
    ]
  }
}
```

`lastImportedAt` and `stale` are based on when numbers were **first added**, not on when you last ran an import. Re-importing an export that contains only numbers already on the list adds no rows, so the registry keeps showing as stale. Keep your own record of when each scrub ran. Jelliu reports stale registries but does not stop calls because of them.

**Lift one number.** `DELETE /api/compliance/suppressions` with `{ "phoneNumber": "+573001112233", "justification": "..." }` removes a single number. `phoneNumber` must match the stored E.164 value exactly, and `justification` (10 to 500 characters) is written to the audit log. The response is `{ "data": { "removed": true } }`, or `false` if the number was not on the list. There is no bulk removal.

## Opt-out and suppression

**Opt-out means total silence.** When a person asks not to be contacted, Jelliu stops, and it does not send a confirmation message either.

| Channel  | What is detected                                                                                                                                                                                                         | What is written                                                                                                                                                                                                           |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Voice    | A request not to be called again, detected in the call transcript in Spanish or English, such as "no me vuelvan a llamar", "quítenme de la lista" or "stop calling me".                                                  | The number is added to the suppression list (source `voice`), and the call's contact is set to `dnc`. The suppression is written even for inbound and manual calls with no contact row.                                   |
| WhatsApp | A message that is only an opt-out keyword, such as `STOP`, `BAJA`, `CANCELAR`, `unsubscribe` or `dar de baja` (trailing punctuation ignored), or a longer phrase like "dejen de llamarme" or "remove me from your list". | Every contact of the workspace with that number (as `phone_number` or `whatsapp_number`) is set to `dnc` with `opted_out_at` and `opted_out_reason`, and the number is added to the suppression list (source `whatsapp`). |
| Email    | An unsubscribe request in a reply.                                                                                                                                                                                       | Every contact with that address (ignoring case) is set to `dnc` with `opted_out_at` and `opted_out_reason`. No auto-reply is sent.                                                                                        |

A keyword inside a sentence is not an opt-out: "quiero cancelar mi cita" is a request to reschedule, not to stop.

**Before every outbound call**, the compliance gate refuses the number if it is on the suppression list, if the contact is `dnc`, or (when the country's compliance configuration requires a DNC check) if any contact of the workspace with that phone or WhatsApp number is `dnc`. The suppression list check applies regardless of configuration. Because the list is keyed by number, re-importing a suppressed number, or adding it to another campaign, does not make it callable.

There is no endpoint to change a contact's status back from `dnc`. If a person gives renewed consent, lift the number from the suppression list with a written justification, and add them again so the new consent is on record.

## Errors

| Code                  | Status | When                                                                                                                                                                                                                                                                                                                       |
| --------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_FAILED`   | 400    | The body failed validation (`Invalid contact input`, `Invalid bulk contacts input`, `Invalid contact`), with `details`. Also `Invalid campaign ID` for a malformed UUID, and `Invalid query params` on list endpoints.                                                                                                     |
| `VALIDATION_FAILED`   | 400    | `Cannot add contacts to a completed or archived campaign`.                                                                                                                                                                                                                                                                 |
| `VALIDATION_FAILED`   | 400    | CSV problems: `No file uploaded`, `Only CSV files are allowed (.csv)`, `Uploaded file is too large (max 10MB).`, `File appears to be a ZIP/XLSX archive, not a CSV`, `CSV must be UTF-8 encoded (...)`, `File is empty or could not be parsed`, `File changed between preview and confirm`, `Invalid upload confirm body`. |
| `VALIDATION_FAILED`   | 422    | `POST /api/contacts` in a workspace with no agents: `No agent configured for this tenant — create an agent before sending manual conversations`.                                                                                                                                                                           |
| `CAMPAIGN_NOT_FOUND`  | 404    | The campaign does not exist or belongs to another workspace.                                                                                                                                                                                                                                                               |
| `BILLING_ERROR`       | 403    | The plan's contact limit is reached, or a bulk batch would exceed it. `metadata` carries `limit`, `current`, `tier` and, for batches, `batch`. Also returned when the workspace has no active plan.                                                                                                                        |
| `FORBIDDEN`           | 403    | A `read` key on a `POST`, or a key without the `full` scope on the suppression list endpoints.                                                                                                                                                                                                                             |
| `RATE_LIMIT_EXCEEDED` | 429    | More than 5 requests per minute under `/api/campaigns/{campaignId}/contacts`: `Too many bulk import requests, please wait before importing again`.                                                                                                                                                                         |
| `SERVICE_UNAVAILABLE` | 503    | A temporary database problem. Retry after `Retry-After`.                                                                                                                                                                                                                                                                   |

A limit error on a batch looks like this:

```json
{
  "error": {
    "code": "BILLING_ERROR",
    "message": "Import of 2000 contacts would exceed the plan limit (2000 contacts on the growth plan; currently 1200). Reduce the batch or upgrade your plan in Settings → Plan.",
    "metadata": { "limit": 2000, "current": 1200, "batch": 2000, "tier": "growth" }
  }
}
```

See [Errors](/errors) for the envelope and retry guidance.

## Limits

**Rate limits.** Everything under `/api/campaigns/{campaignId}/contacts`, including the list, the CSV preview and the template download, shares a budget of **5 requests per minute** per workspace, on top of the general limit. A CSV import (preview plus confirm) uses two. `/api/contacts` and `/api/compliance/suppressions` use the general per-plan limit. See [Rate limits](/rate-limits).

**Plan limits.** The contact limit counts every live contact row in the workspace, across all campaigns including the system campaign. The same number in two campaigns counts twice.

| Plan           | Contacts |
| -------------- | -------- |
| No active plan | 0        |
| Starter        | 500      |
| Growth         | 2,000    |
| Business       | 20,000   |
| Enterprise     | 999,999  |

Single creates are refused once the limit is reached. Bulk imports are refused when `current + batch` would exceed it, so a batch is never partially inserted.

**Size limits.**

| Operation                 | Limit                                                   |
| ------------------------- | ------------------------------------------------------- |
| JSON bulk                 | 5,000 contacts per request, 1 MB request body           |
| CSV upload                | 10 MB file, 500 columns, imported in chunks of 500 rows |
| Metadata (JSON endpoints) | 20 keys, 500 characters per value                       |
| Suppression import        | 200,000 numbers per request, 1 MB request body          |

**Scopes.** See [Authentication](/authentication).

| Endpoint                                                                                                                  | Scope   |
| ------------------------------------------------------------------------------------------------------------------------- | ------- |
| `GET /api/contacts`, `GET /api/campaigns/{campaignId}/contacts`, `GET .../upload-template`                                | `read`  |
| `POST /api/contacts`, `POST /api/campaigns/{campaignId}/contacts`, `.../bulk`, `.../upload-preview`, `.../upload-confirm` | `write` |
| `POST /api/compliance/suppressions/import`, `GET /api/compliance/suppressions`, `DELETE /api/compliance/suppressions`     | `full`  |

## Webhooks

The `contact.created`, `contact.updated`, `contact.status_changed`, `contact.converted` and `contact.dnc` events are accepted in webhook subscriptions but **not delivered yet**. To follow what happens to your contacts today, subscribe to:

| Event                                   | Relevance                                                                                  |
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
| `call.completed`                        | Carries `contactId`, `phoneNumber`, `outcome` and `dataCollection` for each finished call. |
| `call.failed`                           | Carries `contactId` for calls that failed or could not connect.                            |
| `campaign.completed`                    | Sent when no contact in the campaign is left `pending`.                                    |
| `crm_sync.completed`, `crm_sync.failed` | A contact's interaction was, or was not, written to your CRM.                              |

See [Webhooks](/webhooks) for payloads and signature verification.

## Related

#### [Campaigns](/resources/campaigns)

Schedules, channels and the activation state machine that works through your contacts.

#### [Calls](/resources/calls)

Statuses, outcomes and compliance checks for each call placed to a contact.

#### [Conversations](/resources/conversations)

Every message and call with a contact, across channels.

#### [Pagination](/pagination)

Page-number paging for contact lists.

#### [API reference](/api-reference)

Every contact endpoint, parameter and response.