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

# Campaigns

A **campaign** binds an [agent](/resources/agents) to a list of [contacts](/resources/contacts) and a goal. On the `voice` channel, activating a campaign queues a call for every pending contact and dials them within the campaign's schedule, retrying people who did not answer. On `whatsapp` and `email`, activation either sends an opening message to every contact or only arms the agent to answer people who write in, depending on how the campaign is configured. Campaigns start as drafts, so nothing reaches a customer until you activate one.

The [Quickstart](/quickstart) creates a first campaign end to end. This page is the full reference: the object, the state machine, how contacts are worked through, and the limits that apply.

## How it works

```mermaid
stateDiagram-v2
  [*] --> draft: POST /api/campaigns
  draft --> active: PATCH .../activate
  active --> paused: PATCH .../pause
  active --> paused: paused by Jelliu (blocked_reason set)
  paused --> active: PATCH .../activate
  active --> completed: no pending contacts and no calls in flight
  completed --> [*]
```

1. **Create** the campaign. It is stored in `draft` and nothing is sent.
2. **Add contacts** to it, one at a time, in bulk or from a CSV file. See [Contacts](/resources/contacts).
3. **Activate** it. Jelliu runs the channel's preflight checks, flips the status to `active` and queues the outreach.
4. **Workers** process each contact. Before every dial or send they re-read the campaign, so a pause or a channel change takes effect on jobs that are already queued.
5. **Completion** is automatic. A campaign moves to `completed` when it has no `pending` contacts and no call still in progress. The check runs whenever a contact settles, and a background sweep asks again every 15 minutes.

### Status transitions

Only two endpoints change the status: `activate` and `pause`. There is no endpoint that completes or archives a campaign.

| Current status          | Request                                      | Result                                                                                                                                             |
| ----------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `draft`                 | `PATCH /api/campaigns/{campaignId}/activate` | `active`, and outreach is queued.                                                                                                                  |
| `paused`                | `PATCH /api/campaigns/{campaignId}/activate` | `active`. Only contacts still `pending` are queued again, so the run resumes where it stopped.                                                     |
| `active`                | `PATCH /api/campaigns/{campaignId}/activate` | `200` with the campaign unchanged. Nothing is queued a second time.                                                                                |
| `completed`, `archived` | `PATCH /api/campaigns/{campaignId}/activate` | `400 CAMPAIGN_NOT_ACTIVE`: `Campaign can only be activated from DRAFT or PAUSED status`                                                            |
| `active`                | `PATCH /api/campaigns/{campaignId}/pause`    | `paused`.                                                                                                                                          |
| any other               | `PATCH /api/campaigns/{campaignId}/pause`    | `400 CAMPAIGN_NOT_ACTIVE`: `Campaign can only be paused when ACTIVE`                                                                               |
| `active`                | none (automatic)                             | `completed` once no contact is `pending` and no call is in flight.                                                                                 |
| `active`                | none (automatic)                             | `paused` with `blocked_reason` set, when the problem belongs to the workspace rather than to a contact. See [Paused by Jelliu](#paused-by-jelliu). |

Two other rules depend on the status:

* `PATCH /api/campaigns/{campaignId}` is refused for `completed` and `archived` campaigns.
* `DELETE /api/campaigns/{campaignId}` is refused for `active` campaigns. Pause first.

`archived` exists in the status enum, but no API operation moves a campaign into it today. Treat it as a terminal status if you encounter it.

### Channels

| Channel    | What activation does                                                                                                                                                                       |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `voice`    | Queues one dial per `pending` contact. Calls are placed within the schedule, paced by the concurrency limits, and retried when nobody answers.                                             |
| `whatsapp` | With `whatsappTemplateId` set: sends that approved template to every contact. Without it: the campaign is **inbound-only** and the agent answers people who write to your WhatsApp number. |
| `email`    | With both `emailSubject` and `emailBody` set: sends the email to every contact. With neither: inbound-only. Setting just one of them is refused at activation.                             |
| `webchat`  | Inbound-only. Activation arms the agent; nothing is sent.                                                                                                                                  |

The agent must serve the campaign's channel (its `channels` list). A mismatch is refused on create, on a channel change and again on activation, with `400 VALIDATION_FAILED`.

The schedule gates **voice** dialing only. WhatsApp and email outreach is paced (sends are spaced out per worker) but it is not held to the schedule's days and hours: activating a WhatsApp or email campaign at 23:00 sends at 23:00.

### How voice contacts are worked through

For every queued contact, the dialer checks, in order:

1. **The campaign is still `active` and still a voice campaign.** Otherwise the job ends and the contact stays `pending`.
2. **The schedule.** Outside the window, the dial is postponed to the start of the next enabled window, up to 14 times. A contact that exceeds that, or a schedule with no upcoming window at all, is marked `failed`.
3. **The contact is still `pending`**, and its **current** phone number is used, not the one it had when the campaign was activated.
4. **The number is dialable.** A value that is not E.164, or that the carrier lookup reports as unroutable, marks the contact `invalid` without spending an attempt.
5. **Consent.** If the workspace requires consent before calling, a contact without voice-call consent is marked `invalid`.
6. **Claim.** The contact is atomically moved to `called`, so two workers can never dial the same person.
7. **The call.** A contact blocked by compliance rules or the do-not-call list is marked `dnc`. See [Calls](/resources/calls) for everything that happens once the call is placed.

**Concurrency.** A call is placed only if the campaign has fewer live calls than its `max_concurrent_calls` and the workspace has a free slot under its plan's concurrent-call limit. When either limit is reached, the dial is not failed: the contact returns to `pending` and the dial is rescheduled with a backoff of 30 seconds growing to 10 minutes.

**Retries.** When a call ends without reaching the person (no answer, busy line, or a call that never connected), Jelliu schedules another dial after `retry_interval_minutes`. `max_retry_attempts` is the **total** number of dials per contact in a run, including the first one: with the default of `3`, a contact is called at most three times. `0` disables retries. Retries go back through the same checks, so they also respect the schedule. Contacts that are `converted`, `dnc` or `invalid` are never retried.

### Campaign context reaches the agent

`name`, `productContext` and `targetAudience` are not just labels. On every campaign call they are passed to the agent as the `campaign_name`, `product_context` and `target_audience` variables, together with the contact's name and your company name. Text conversations (WhatsApp, email, web chat) with a contact that belongs to a campaign receive the same three variables. Write `productContext` as the brief you would give a human agent: what is being offered and what the conversation is for.

### Paused by Jelliu

Some failures belong to the workspace, not to a contact. When a dial fails because the workspace has no phone number of its own (`PHONE_NUMBER_REQUIRED`) or because the campaign's agent is paused (`AGENT_PAUSED`), Jelliu pauses the **campaign**, writes the reason to `blocked_reason` and leaves every remaining contact `pending`. Fix the cause and activate the campaign again to resume.

`blocked_reason` is not cleared when the campaign is reactivated. Read it together with `status`: it only describes the current state while the campaign is `paused`.

## The campaign object

Campaign responses are the stored row, with **snake\_case** field names, even though request bodies use camelCase.

| Field                            | Type               | Nullable | Description                                                                                                                                                                                |
| -------------------------------- | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`                             | string (uuid)      | No       | Unique identifier.                                                                                                                                                                         |
| `tenant_id`                      | string (uuid)      | No       | The workspace that owns the campaign.                                                                                                                                                      |
| `agent_id`                       | string (uuid)      | No       | The agent that runs the campaign. Cannot be changed after creation.                                                                                                                        |
| `name`                           | string             | No       | 1 to 200 characters. Reaches the agent as `campaign_name`.                                                                                                                                 |
| `product_context`                | string             | No       | 10 to 5000 characters. Reaches the agent as `product_context`.                                                                                                                             |
| `target_audience`                | string             | No       | 1 to 1000 characters. Reaches the agent as `target_audience`.                                                                                                                              |
| `status`                         | string             | No       | `draft`, `active`, `paused`, `completed` or `archived`.                                                                                                                                    |
| `blocked_reason`                 | string             | Yes      | Why Jelliu paused the campaign, in Spanish, naming what to fix. `null` when a person paused it or it was never paused by the system.                                                       |
| `channel`                        | string             | No       | `voice`, `whatsapp`, `email` or `webchat`.                                                                                                                                                 |
| `category`                       | string             | No       | `sales`, `support`, `scheduling`, `surveys`, `collections`, `retention`, `notifications`, `interview`, `language_assessment` or `general`. Decides which call outcomes count as a success. |
| `schedule`                       | object             | No       | `{ timezone, days }`. See [Schedule](#schedule).                                                                                                                                           |
| `max_concurrent_calls`           | integer            | No       | Live calls this campaign may hold at once. Stored already clamped to your plan's concurrent-call limit.                                                                                    |
| `max_retry_attempts`             | integer            | No       | Total dials per contact in a run, including the first. `0` disables retries.                                                                                                               |
| `retry_interval_minutes`         | integer            | No       | Minutes between a missed call and the next dial.                                                                                                                                           |
| `whatsapp_template_id`           | string (uuid)      | Yes      | WhatsApp only. The approved template sent on activation. `null` means inbound-only.                                                                                                        |
| `whatsapp_template_variables`    | array or object    | Yes      | WhatsApp only. Campaign-wide values for the template placeholders.                                                                                                                         |
| `whatsapp_template_variable_map` | object             | Yes      | WhatsApp only. Placeholders filled per contact. See [Template variables](#template-variables).                                                                                             |
| `email_subject`                  | string             | Yes      | Email only. Subject line, may contain tokens.                                                                                                                                              |
| `email_body`                     | string             | Yes      | Email only. Message body, may contain tokens.                                                                                                                                              |
| `retry_policy`                   | object             | Yes      | Legacy column. Not settable through the API; ignore it.                                                                                                                                    |
| `voicemail_action`               | string             | Yes      | Legacy column. Not settable through the API; ignore it.                                                                                                                                    |
| `voicemail_message`              | string             | Yes      | Legacy column. Not settable through the API; ignore it.                                                                                                                                    |
| `created_at`                     | string (date-time) | No       | When the campaign was created.                                                                                                                                                             |
| `updated_at`                     | string (date-time) | No       | Last change to the row.                                                                                                                                                                    |
| `deleted_at`                     | string (date-time) | Yes      | Always `null` in responses: deleted campaigns are not returned.                                                                                                                            |

### Fields added by the list endpoint

`GET /api/campaigns` returns a lighter row. It includes `id`, `tenant_id`, `agent_id`, `name`, `status`, `category`, `channel`, `max_concurrent_calls`, `max_retry_attempts`, `schedule`, `blocked_reason`, `created_at`, `updated_at` and `deleted_at`, plus these aggregates. It does **not** include `product_context`, `target_audience`, `retry_interval_minutes` or the WhatsApp and email fields; fetch the campaign by ID for those.

| Field                | Type    | Nullable | Description                                                                                                           |
| -------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `agent_name`         | string  | Yes      | Name of the campaign's agent.                                                                                         |
| `total_contacts`     | integer | No       | Contacts in the campaign.                                                                                             |
| `pending_contacts`   | integer | No       | Contacts with status `pending`.                                                                                       |
| `called_contacts`    | integer | No       | Contacts that have been dialed at least once.                                                                         |
| `converted_contacts` | integer | No       | Contacts with status `converted`.                                                                                     |
| `conversion_rate`    | number  | No       | `converted_contacts` divided by `called_contacts`, as a percentage with one decimal. `0` when nobody has been called. |

`called_contacts` and `conversion_rate` count **phone calls** only. On `whatsapp` and `email` campaigns they stay at `0` even after every contact has been messaged. Use `total_contacts` and `pending_contacts` to track progress on those channels.

### Schedule

| Field              | Type    | Rules                                                                                    |
| ------------------ | ------- | ---------------------------------------------------------------------------------------- |
| `timezone`         | string  | A valid IANA time zone, for example `America/Bogota`.                                    |
| `days`             | array   | At least one item, and at least one item with `enabled: true`.                           |
| `days[].day`       | string  | `monday` through `sunday`.                                                               |
| `days[].startHour` | integer | 0 to 23. The first hour calls may start, in `timezone`.                                  |
| `days[].endHour`   | integer | 1 to 24. Calls start only **before** this hour.                                          |
| `days[].enabled`   | boolean | Days with `false` are skipped. On enabled days `startHour` must be lower than `endHour`. |

Hours are whole hours: `startHour: 9, endHour: 18` allows calls to start from 09:00 up to 17:59. A day that does not appear in `days` is treated as disabled.

### Template variables

WhatsApp templates contain numbered placeholders such as `{{1}}`. You can fill them two ways, and combine them:

* **`whatsappTemplateVariables`**: the same value for every contact. Either an array of up to 20 strings, or an object keyed by placeholder number (keys up to 8 characters). Values are up to 500 characters.
* **`whatsappTemplateVariableMap`**: a value resolved **per contact**, keyed by placeholder number (keys up to 3 characters). Each entry is either a token string, or `{ "token": "...", "fallback": "..." }` with a fallback of up to 500 characters. Placeholders the map does not mention keep their campaign-wide value.

Email campaigns write the same tokens inline in `emailSubject` and `emailBody`, wrapped in double braces, for example `Hola {{contact.first_name}}`.

| Token                  | Resolves to                                                                                                                            |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `contact.name`         | The contact's name.                                                                                                                    |
| `contact.first_name`   | The first word of the contact's name.                                                                                                  |
| `contact.email`        | The contact's email.                                                                                                                   |
| `contact.phone`        | The contact's WhatsApp number, or phone number, when it is a real E.164 number.                                                        |
| `campaign.name`        | The campaign's name.                                                                                                                   |
| `contact.metadata.KEY` | A custom field of the contact, where `KEY` is 1 to 64 letters, digits, spaces, `_`, `.` or `-`. CSV imports store column headers here. |

Unknown tokens are rejected when you save the campaign. A mapped placeholder number that the template does not have is rejected too.

A contact whose tokens do not resolve, and whose map entry has no `fallback`, is **skipped** rather than sent a message with a gap in it. Skipped contacts, and contacts with no WhatsApp number or email address, are set to `invalid` during activation. Adding the missing data later does not put them back in the queue.

## Common tasks

### Launch a voice campaign

#### Create the campaign

Creating campaigns requires a `full` key.

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/campaigns" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    "name": "Renovaciones septiembre",
    "productContext": "Llamadas a clientes cuyo plan de internet hogar vence este mes para ofrecer la renovación con 20% de descuento.",
    "targetAudience": "Clientes residenciales con contrato por vencer",
    "channel": "voice",
    "category": "retention",
    "maxConcurrentCalls": 5,
    "maxRetryAttempts": 3,
    "retryIntervalMinutes": 120,
    "schedule": {
      "timezone": "America/Bogota",
      "days": [
        { "day": "monday",    "startHour": 9, "endHour": 18, "enabled": true },
        { "day": "tuesday",   "startHour": 9, "endHour": 18, "enabled": true },
        { "day": "wednesday", "startHour": 9, "endHour": 18, "enabled": true },
        { "day": "thursday",  "startHour": 9, "endHour": 18, "enabled": true },
        { "day": "friday",    "startHour": 9, "endHour": 17, "enabled": true },
        { "day": "saturday",  "startHour": 9, "endHour": 13, "enabled": false }
      ]
    }
  }'
```

**`Node.js`**

```javascript title="Node.js"
const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'];

const res = await fetch('https://api.jelliu.co/api/campaigns', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    agentId: '7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4',
    name: 'Renovaciones septiembre',
    productContext:
      'Llamadas a clientes cuyo plan de internet hogar vence este mes para ofrecer la renovación con 20% de descuento.',
    targetAudience: 'Clientes residenciales con contrato por vencer',
    channel: 'voice',
    category: 'retention',
    maxConcurrentCalls: 5,
    maxRetryAttempts: 3,
    retryIntervalMinutes: 120,
    schedule: {
      timezone: 'America/Bogota',
      days: weekdays.map((day) => ({
        day,
        startHour: 9,
        endHour: day === 'friday' ? 17 : 18,
        enabled: true,
      })),
    },
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);

const campaignId = body.data.id; // status is "draft"
```

**`Python`**

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

weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday"]
payload = {
    "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    "name": "Renovaciones septiembre",
    "productContext": "Llamadas a clientes cuyo plan de internet hogar vence este mes para ofrecer la renovación con 20% de descuento.",
    "targetAudience": "Clientes residenciales con contrato por vencer",
    "channel": "voice",
    "category": "retention",
    "maxConcurrentCalls": 5,
    "maxRetryAttempts": 3,
    "retryIntervalMinutes": 120,
    "schedule": {
        "timezone": "America/Bogota",
        "days": [
            {"day": d, "startHour": 9, "endHour": 17 if d == "friday" else 18, "enabled": True}
            for d in weekdays
        ],
    },
}

res = requests.post(
    "https://api.jelliu.co/api/campaigns",
    json=payload,
    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']}: {body['error']['message']}")

campaign_id = body["data"]["id"]  # status is "draft"
```

The response is `201 Created`:

```json
{
  "data": {
    "id": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
    "tenant_id": "3e9a1c47-2b8d-4f60-a5c1-7d2e9b4f8a13",
    "agent_id": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    "blocked_reason": null,
    "name": "Renovaciones septiembre",
    "product_context": "Llamadas a clientes cuyo plan de internet hogar vence este mes para ofrecer la renovación con 20% de descuento.",
    "target_audience": "Clientes residenciales con contrato por vencer",
    "status": "draft",
    "schedule": {
      "timezone": "America/Bogota",
      "days": [
        { "day": "monday", "startHour": 9, "endHour": 18, "enabled": true },
        { "day": "tuesday", "startHour": 9, "endHour": 18, "enabled": true },
        { "day": "wednesday", "startHour": 9, "endHour": 18, "enabled": true },
        { "day": "thursday", "startHour": 9, "endHour": 18, "enabled": true },
        { "day": "friday", "startHour": 9, "endHour": 17, "enabled": true },
        { "day": "saturday", "startHour": 9, "endHour": 13, "enabled": false }
      ]
    },
    "max_concurrent_calls": 3,
    "max_retry_attempts": 3,
    "retry_interval_minutes": 120,
    "channel": "voice",
    "category": "retention",
    "retry_policy": {},
    "voicemail_action": "retry",
    "voicemail_message": null,
    "whatsapp_template_id": null,
    "whatsapp_template_variables": null,
    "whatsapp_template_variable_map": null,
    "email_subject": null,
    "email_body": null,
    "created_at": "2026-09-15T14:02:11.482Z",
    "updated_at": "2026-09-15T14:02:11.482Z",
    "deleted_at": null
  }
}
```

In this example the workspace is on Starter, so the requested `maxConcurrentCalls` of 5 was stored as `3`, the plan's concurrent-call limit.

#### Add contacts

Load the people to call. For more than a handful, use one bulk request (up to 5,000 contacts) or a CSV upload, because every route under `/api/campaigns/{campaignId}/contacts` is limited to 5 requests per minute. See [Contacts](/resources/contacts).

**`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" }'
```

**`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' }),
});
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).error?.code}`);
```

**`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"},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
```

#### Activate

**`curl`**

```bash title="curl"
curl -sS -X PATCH "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/activate" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const res = await fetch(`https://api.jelliu.co/api/campaigns/${campaignId}/activate`, {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
console.log(body.data.status); // "active"
```

**`Python`**

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

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"
res = requests.patch(
    f"https://api.jelliu.co/api/campaigns/{campaign_id}/activate",
    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(body["data"]["status"])  # "active"
```

The response is `200` with the campaign in `data`, now `"status": "active"`. Activation checks, in this order:

1. The campaign is `draft` or `paused`.
2. The agent still exists and serves the campaign's channel.
3. Voice: at least one contact is `pending`. Other channels: the campaign has at least one contact.
4. WhatsApp with a template: the template exists, is approved, every placeholder has a value or a mapping, and your WhatsApp sender is online.
5. Email: the workspace sends from its own connected mailbox, the mailbox is healthy, and subject and body are either both set or both empty.
6. Voice: the number of `pending` contacts does not exceed your plan's contact limit.
7. Your plan still has a free active-campaign slot.

Activation starts real calls and messages. The request returns once the outreach is queued, which can take a few seconds for large campaigns.

### Reach out on WhatsApp with per-contact values

This campaign sends an approved template whose body is `Hola {{1}}, tu pedido de {{2}} está listo.` Placeholder `1` is the contact's first name, falling back to `cliente`. Placeholder `2` is the same for everybody.

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/campaigns" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    "name": "Pedidos listos para recoger",
    "productContext": "Avisar a clientes que su pedido está listo y resolver dudas sobre horarios de recogida.",
    "targetAudience": "Clientes con pedidos listos en tienda",
    "channel": "whatsapp",
    "category": "notifications",
    "whatsappTemplateId": "9b2e4d61-8f3a-4c7b-b1e5-2a6d9c0f4e87",
    "whatsappTemplateVariables": { "2": "Tienda Chapinero" },
    "whatsappTemplateVariableMap": {
      "1": { "token": "contact.first_name", "fallback": "cliente" }
    },
    "schedule": {
      "timezone": "America/Bogota",
      "days": [{ "day": "monday", "startHour": 8, "endHour": 20, "enabled": true }]
    }
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/campaigns', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    agentId: '7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4',
    name: 'Pedidos listos para recoger',
    productContext: 'Avisar a clientes que su pedido está listo y resolver dudas sobre horarios de recogida.',
    targetAudience: 'Clientes con pedidos listos en tienda',
    channel: 'whatsapp',
    category: 'notifications',
    whatsappTemplateId: '9b2e4d61-8f3a-4c7b-b1e5-2a6d9c0f4e87',
    whatsappTemplateVariables: { 2: 'Tienda Chapinero' },
    whatsappTemplateVariableMap: {
      1: { token: 'contact.first_name', fallback: 'cliente' },
    },
    schedule: {
      timezone: 'America/Bogota',
      days: [{ day: 'monday', startHour: 8, endHour: 20, enabled: true }],
    },
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
```

**`Python`**

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

payload = {
    "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    "name": "Pedidos listos para recoger",
    "productContext": "Avisar a clientes que su pedido está listo y resolver dudas sobre horarios de recogida.",
    "targetAudience": "Clientes con pedidos listos en tienda",
    "channel": "whatsapp",
    "category": "notifications",
    "whatsappTemplateId": "9b2e4d61-8f3a-4c7b-b1e5-2a6d9c0f4e87",
    "whatsappTemplateVariables": {"2": "Tienda Chapinero"},
    "whatsappTemplateVariableMap": {
        "1": {"token": "contact.first_name", "fallback": "cliente"},
    },
    "schedule": {
        "timezone": "America/Bogota",
        "days": [{"day": "monday", "startHour": 8, "endHour": 20, "enabled": True}],
    },
}

res = requests.post(
    "https://api.jelliu.co/api/campaigns",
    json=payload,
    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']}: {body['error']['message']}")
```

On activation, contacts that have a valid E.164 `phoneNumber` but no WhatsApp number get their phone number copied into the WhatsApp field, so imported lists are reachable. The schedule is required by the schema but does not restrict WhatsApp sends.

To turn a WhatsApp campaign back into an inbound-only one, send `"whatsappTemplateId": null` in a `PATCH`. For email, send `"emailSubject": null` and `"emailBody": null`.

### Turn an email campaign into outreach

`PATCH` accepts every create field except `agentId`, all optional. This request adds a subject and body to an existing `email` campaign, so its next activation mails every contact.

**`curl`**

```bash title="curl"
curl -sS -X PATCH "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "emailSubject": "{{contact.first_name}}, tu renovación está lista",
    "emailBody": "Hola {{contact.first_name}}, responde a este correo y te ayudamos a renovar tu plan hoy mismo."
  }'
```

**`Node.js`**

```javascript title="Node.js"
const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const res = await fetch(`https://api.jelliu.co/api/campaigns/${campaignId}`, {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    emailSubject: '{{contact.first_name}}, tu renovación está lista',
    emailBody: 'Hola {{contact.first_name}}, responde a este correo y te ayudamos a renovar tu plan hoy mismo.',
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
```

**`Python`**

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

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"
res = requests.patch(
    f"https://api.jelliu.co/api/campaigns/{campaign_id}",
    json={
        "emailSubject": "{{contact.first_name}}, tu renovación está lista",
        "emailBody": "Hola {{contact.first_name}}, responde a este correo y te ayudamos a renovar tu plan hoy mismo.",
    },
    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']}: {body['error']['message']}")
```

The response is `200` with the updated campaign. A `PATCH` with no recognized fields returns the campaign unchanged.

An `email` campaign needs a mailbox of your own. Connect Gmail, Outlook or Zoho Mail under **Integrations**, then select it as the sender under **Settings → Account → Email**. Connecting it is not enough. Without that, creating or activating an email campaign fails with `409 VALIDATION_FAILED`; mail is never sent from a platform address instead.

Changing `channel` on an **active** campaign stops the old channel's queued jobs but does not queue anything on the new one. Pause the campaign and activate it again so the remaining `pending` contacts are queued on the new channel.

### Pause and resume

Pausing does not delete queued work. Each queued dial or send checks the status when its turn comes and does nothing while the campaign is paused. Calls already in progress finish normally.

**`curl`**

```bash title="curl"
# Pause
curl -sS -X PATCH "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/pause" \
  -H "Authorization: Bearer $JELLIU_API_KEY"

# Resume: activate again
curl -sS -X PATCH "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/activate" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const base = 'https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const headers = { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` };

async function transition(action) {
  const res = await fetch(`${base}/${action}`, { method: 'PATCH', headers });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
  return body.data;
}

await transition('pause');    // status: "paused"
await transition('activate'); // status: "active"
```

**`Python`**

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

base = "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"
headers = {"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"}

def transition(action):
    res = requests.patch(f"{base}/{action}", headers=headers, timeout=60)
    body = res.json()
    if not res.ok:
        raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")
    return body["data"]

transition("pause")     # status: "paused"
transition("activate")  # status: "active"
```

A resumed voice campaign queues only the contacts still `pending`. People already called in the earlier run are not called again, except through the normal retry rules.

### List campaigns and track progress

`GET /api/campaigns` uses [page-number pagination](/pagination#page-number): `page` (default 1) and `limit` (1 to 100, default 20), newest first.

**`curl`**

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

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/campaigns?page=1&limit=20', {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}`);

for (const c of body.data) {
  console.log(c.name, c.status, `${c.pending_contacts}/${c.total_contacts} pending`);
}
```

**`Python`**

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

res = requests.get(
    "https://api.jelliu.co/api/campaigns",
    params={"page": 1, "limit": 20},
    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']}")

for c in body["data"]:
    print(c["name"], c["status"], f"{c['pending_contacts']}/{c['total_contacts']} pending")
```

```json
{
  "data": [
    {
      "id": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
      "tenant_id": "3e9a1c47-2b8d-4f60-a5c1-7d2e9b4f8a13",
      "agent_id": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
      "name": "Renovaciones septiembre",
      "status": "active",
      "category": "retention",
      "channel": "voice",
      "max_concurrent_calls": 3,
      "max_retry_attempts": 3,
      "schedule": {
        "timezone": "America/Bogota",
        "days": [{ "day": "monday", "startHour": 9, "endHour": 18, "enabled": true }]
      },
      "created_at": "2026-09-15T14:02:11.482Z",
      "updated_at": "2026-09-15T14:05:40.019Z",
      "deleted_at": null,
      "blocked_reason": null,
      "agent_name": "Laura - Renovaciones",
      "total_contacts": 250,
      "pending_contacts": 164,
      "called_contacts": 86,
      "converted_contacts": 12,
      "conversion_rate": 14
    }
  ],
  "meta": { "page": 1, "limit": 20 }
}
```

The response has no total count: stop when a page returns fewer than `limit` items. `GET /api/campaigns/{campaignId}` returns one campaign with every field from [the campaign object](#the-campaign-object), in `data`.

Campaign reads are cached briefly: the list for up to 60 seconds and a single campaign for up to 120 seconds. Your own create, update, activate, pause and delete requests refresh the cache immediately, but changes Jelliu makes on its own (automatic completion, a system pause, contacts being worked through) can take that long to appear. Use the `campaign.completed` webhook rather than polling for completion.

### Read campaign results

`GET /api/analytics/campaigns/{campaignId}` summarizes the campaign's **calls**. The optional `direction` query parameter accepts `inbound`, `outbound` or `all` (default).

**`curl`**

```bash title="curl"
curl -sS "https://api.jelliu.co/api/analytics/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const res = await fetch(`https://api.jelliu.co/api/analytics/campaigns/${campaignId}`, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const { data } = await res.json();
console.log(`${data.successRateLabel}: ${data.successRate}%`);
```

**`Python`**

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

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"
res = requests.get(
    f"https://api.jelliu.co/api/analytics/campaigns/{campaign_id}",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
data = res.json()["data"]
print(f"{data['successRateLabel']}: {data['successRate']}%")
```

```json
{
  "data": {
    "campaignId": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
    "channel": "voice",
    "category": "retention",
    "totalInteractions": 131,
    "completedInteractions": 86,
    "successes": 12,
    "successRate": 13.95,
    "successRateLabel": "retentionRate",
    "averageDurationSeconds": 143,
    "avgSentiment": 0.21,
    "outcomeBreakdown": {
      "customer_retained": 11,
      "customer_reactivated": 1,
      "callback_scheduled": 9,
      "rejected": 31,
      "no_answer": 34
    },
    "excludedMetrics": []
  }
}
```

| Field                    | Description                                                                                                                                                                 |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `totalInteractions`      | Calls in the campaign, any status.                                                                                                                                          |
| `completedInteractions`  | Calls with status `completed`.                                                                                                                                              |
| `successes`              | Calls whose outcome counts as a success for the campaign's `category`. For `retention`, that is `customer_retained` and `customer_reactivated`; for `sales`, `sale_closed`. |
| `successRate`            | `successes` divided by `completedInteractions`, as a percentage from 0 to 100 with two decimals.                                                                            |
| `successRateLabel`       | What the rate means for the category, for example `conversionRate`, `resolutionRate`, `bookingRate` or `retentionRate`.                                                     |
| `averageDurationSeconds` | Average length of completed calls. `null` for non-voice campaigns.                                                                                                          |
| `avgSentiment`           | Average sentiment from -1 to 1 over calls that were scored. `null` when none were, never `0` for "no data".                                                                 |
| `outcomeBreakdown`       | Count of calls per outcome. See [Calls](/resources/calls) for the outcome values.                                                                                           |
| `excludedMetrics`        | Metrics that do not apply to this channel or category.                                                                                                                      |
| `_degraded`              | Present only when part of the computation failed; lists which parts. Treat the related fields as incomplete.                                                                |

Results are cached for up to two minutes. For WhatsApp and email campaigns this endpoint reports zeros, because they produce conversations rather than calls; read those through [Conversations](/resources/conversations). For call-by-call detail, list `GET /api/calls?campaignId=...`.

### Delete a campaign

Deletion is a soft delete: the campaign stops appearing in the API and its ID returns `404`. An active campaign must be paused first.

**`curl`**

```bash title="curl"
curl -sS -X DELETE "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const res = await fetch(`https://api.jelliu.co/api/campaigns/${campaignId}`, {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
if (res.status !== 204) throw new Error(`${res.status} ${(await res.json()).error?.code}`);
```

**`Python`**

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

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"
res = requests.delete(
    f"https://api.jelliu.co/api/campaigns/{campaign_id}",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
if res.status_code != 204:
    raise RuntimeError(f"{res.status_code} {res.json()['error']['code']}")
```

A successful delete returns `204 No Content` with an empty body.

## Errors

Every error uses the [standard envelope](/errors). Codes specific to campaigns:

| Code                  | Status | When                                                                                                                                                                                         |
| --------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_FAILED`   | 400    | The body failed the schema (`Invalid campaign input` on create, `Invalid update input` on update, with field errors in `details`), or the campaign ID is not a UUID (`Invalid campaign ID`). |
| `VALIDATION_FAILED`   | 400    | The agent does not serve the campaign's channel.                                                                                                                                             |
| `VALIDATION_FAILED`   | 400    | An unknown token in `emailSubject`, `emailBody` or `whatsappTemplateVariableMap`, or a mapped placeholder the template does not have.                                                        |
| `VALIDATION_FAILED`   | 400    | Activation: `Campaign has no pending contacts to call` (voice), or the campaign has no contacts (other channels).                                                                            |
| `VALIDATION_FAILED`   | 400    | Activation: the WhatsApp template is missing, not approved, has uncovered placeholders, or the WhatsApp sender is not online.                                                                |
| `VALIDATION_FAILED`   | 400    | Activation: only one of `emailSubject` and `emailBody` is set, or the sending mailbox reports a problem.                                                                                     |
| `VALIDATION_FAILED`   | 400    | Activation: the voice campaign has more `pending` contacts than your plan allows.                                                                                                            |
| `VALIDATION_FAILED`   | 400    | `Cannot update a completed or archived campaign`, or `Cannot delete an active campaign — pause it first`.                                                                                    |
| `VALIDATION_FAILED`   | 409    | An `email` campaign was created or activated without a selected mailbox of your own, or the mailbox could not be checked.                                                                    |
| `CAMPAIGN_NOT_ACTIVE` | 400    | Activating a campaign that is not `draft` or `paused`, or pausing one that is not `active`.                                                                                                  |
| `CAMPAIGN_NOT_ACTIVE` | 409    | `Campaign status changed concurrently`: another request changed the status first. Read the campaign again.                                                                                   |
| `BILLING_ERROR`       | 403    | Creating or activating would exceed the plan's active-campaign limit, or there is no active plan. `metadata` carries `limit`, `current` and `tier`.                                          |
| `FORBIDDEN`           | 403    | A `read` or `write` key called a mutating campaign route. These require `full`.                                                                                                              |
| `CAMPAIGN_NOT_FOUND`  | 404    | The campaign does not exist, was deleted, or belongs to another workspace.                                                                                                                   |
| `AGENT_NOT_FOUND`     | 404    | The `agentId` on create, or the campaign's agent on activation or channel change, does not exist in your workspace.                                                                          |
| `RATE_LIMIT_EXCEEDED` | 429    | More than 10 campaign mutations in a minute. See [Limits](#limits).                                                                                                                          |

## Limits

### Scopes

| Operation                                                                                            | Minimum scope |
| ---------------------------------------------------------------------------------------------------- | ------------- |
| `GET /api/campaigns`, `GET /api/campaigns/{campaignId}`, `GET /api/analytics/campaigns/{campaignId}` | `read`        |
| `POST /api/campaigns`, `PATCH /api/campaigns/{campaignId}`, `DELETE /api/campaigns/{campaignId}`     | `full`        |
| `PATCH /api/campaigns/{campaignId}/activate`, `PATCH /api/campaigns/{campaignId}/pause`              | `full`        |

Adding contacts to a campaign needs only `write`. See [Authentication](/authentication).

### Rate limits

* All campaign routes count against the [general API limit](/rate-limits#general-api-limit).
* Create, update, delete, activate and pause also share the **10 per minute** configuration-mutation budget with agent and webhook mutations.
* Routes under `/api/campaigns/{campaignId}/contacts` are limited to **5 per minute**.
* `GET /api/analytics/campaigns/{campaignId}` is limited to **30 per minute**.

### Plan limits

| Limit                                 | Starter | Growth | Business  | Enterprise    |
| ------------------------------------- | ------- | ------ | --------- | ------------- |
| Active campaigns                      | 1       | 3      | Unlimited | Unlimited     |
| Concurrent calls (workspace)          | 3       | 10     | 25        | No plan limit |
| Pending contacts per voice activation | 500     | 2,000  | 20,000    | 999,999       |

* **Only `active` campaigns use a slot.** Drafts, paused and completed campaigns do not. Pausing a campaign frees its slot; activating one takes it.
* **Creating a draft also needs a free slot.** When your active campaigns already fill the plan, `POST /api/campaigns` is refused with `403 BILLING_ERROR` even though the new campaign would start as a draft. Pause or finish a running campaign first.
* **`maxConcurrentCalls` is clamped** to your plan's concurrent-call limit when you save it, so the stored value is the effective one.
* A workspace with no active plan cannot create or activate campaigns.

Jelliu also creates a system campaign named **Manual Conversations** to hold contacts from web chat and messages started from the dashboard. It is excluded from `GET /api/campaigns` and never counts against your active-campaign limit.

### Request bounds

| Field                  | Bounds                                  | Default  |
| ---------------------- | --------------------------------------- | -------- |
| `name`                 | 1 to 200 characters                     | Required |
| `productContext`       | 10 to 5000 characters                   | Required |
| `targetAudience`       | 1 to 1000 characters                    | Required |
| `maxConcurrentCalls`   | 1 to 500, then clamped to the plan      | `10`     |
| `maxRetryAttempts`     | 0 to 10                                 | `3`      |
| `retryIntervalMinutes` | 5 to 1440                               | `60`     |
| `channel`              | `voice`, `whatsapp`, `webchat`, `email` | `voice`  |
| `category`             | See [the object](#the-campaign-object)  | `sales`  |
| `emailSubject`         | 1 to 300 characters                     | None     |
| `emailBody`            | 1 to 20000 characters                   | None     |

## Webhooks

| Event                                 | Sent    | When                                                                                   |
| ------------------------------------- | ------- | -------------------------------------------------------------------------------------- |
| `campaign.completed`                  | Yes     | The campaign moved to `completed`. `data` carries `campaignId` and `campaignName`.     |
| `call.completed`, `call.failed`       | Yes     | A campaign call ended. `data.campaignId` identifies the campaign.                      |
| `campaign.started`, `campaign.paused` | Not yet | Accepted in subscriptions but not delivered today. Read `status` from the API instead. |

Narrow deliveries to specific campaigns with the `campaignIds` filter. See [Webhooks](/webhooks).

## Related

#### [Contacts](/resources/contacts)

Add, import and manage the people a campaign reaches.

#### [Agents](/resources/agents)

The agent a campaign runs, and the channels it serves.

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

Call statuses, outcomes, recordings and transcripts.

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

WhatsApp, email and web chat threads started by campaigns.

#### [Phone numbers](/resources/phone-numbers)

The number voice campaigns dial from.

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

Every campaign endpoint, parameter and response.