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

# Run an outbound voice campaign

In this recipe you launch an outbound calling campaign entirely through the API. By the end you will have an AI voice agent calling a list of contacts inside the hours you choose, a signed webhook delivering every call result to your server, and a script that reads outcomes, summaries and collected data back from Jelliu.

Every request, body and response below matches the live API. Examples use `https://api.jelliu.co`, Node.js 18+ (global `fetch`) and Python 3.9+ with `requests`.

## How it works

```mermaid
sequenceDiagram
    autonumber
    participant You as Your server
    participant API as api.jelliu.co
    participant Dialer as Jelliu dialer
    participant Lead as Contact's phone
    You->>API: POST /api/agents
    You->>API: POST /api/webhooks (call.completed, call.failed, campaign.completed)
    You->>API: POST /api/campaigns (draft)
    You->>API: POST /api/campaigns/ID/contacts/bulk
    You->>API: PATCH /api/campaigns/ID/activate
    API->>Dialer: Enqueue pending contacts
    loop Inside the campaign schedule
        Dialer->>Dialer: Schedule window, compliance and plan checks
        Dialer->>Lead: Call with the agent
        Lead-->>Dialer: Conversation ends
        Dialer->>API: Analysis (outcome, summary, data collection)
        API->>You: POST call.completed or call.failed (signed)
    end
    API->>You: POST campaign.completed
    You->>API: GET /api/calls?campaignId=ID
```

A campaign moves through these statuses:

| Status      | Meaning                                                                                                                                            |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `draft`     | Created, not calling. You can add contacts and edit it.                                                                                            |
| `active`    | The dialer is working through `pending` contacts inside the schedule.                                                                              |
| `paused`    | No new calls start. Set by you, or by Jelliu when the workspace cannot dial (see [When Jelliu pauses a campaign](#when-jelliu-pauses-a-campaign)). |
| `completed` | Every contact reached a final state. `campaign.completed` is sent.                                                                                 |
| `archived`  | Retired. Cannot be activated or edited.                                                                                                            |

## Prerequisites

#### A full-scope API key

Creating, activating and pausing campaigns, and creating webhooks, are admin-only operations, so they need a key with the `full` scope. The workspace owner creates it under **Settings → API Keys**. See [API keys](/platform/api-keys).

```bash
export JELLIU_API_KEY="jl_..."
```

#### An active plan and a phone number

Calls need an active subscription or trial with minutes available. On a paid plan, outbound calls are placed from a phone number owned by the workspace; buy or connect one under **Settings → Numbers**, or see [Phone numbers](/resources/phone-numbers).

#### A public HTTPS endpoint for webhooks

Jelliu only delivers to public addresses; private, internal and loopback URLs are rejected when you create the webhook. For local development, expose your machine through a tunnel and use its HTTPS URL. [CRM sync with webhooks](/recipes/crm-sync-with-webhooks) covers tunnels and idempotent processing in depth.

## Build it

#### Set up a small API client

Every response wraps its payload in `data`, and every error uses the same envelope with a stable `error.code`. This helper surfaces both.

**`Node.js`**

```javascript title="Node.js"
// jelliu.js
const BASE = 'https://api.jelliu.co';

export async function jelliu(method, path, body) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (res.status === 204) return null;
  const json = await res.json();
  if (!res.ok) {
    const err = new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`);
    err.status = res.status;
    err.code = json.error?.code;
    err.details = json.error?.details;
    err.retryAfter = Number(res.headers.get('Retry-After')) || null;
    throw err;
  }
  return json.data;
}
```

**`Python`**

```python title="Python"
# jelliu.py
import os

import requests

BASE = "https://api.jelliu.co"


class JelliuError(Exception):
    def __init__(self, status, code, message, details=None, retry_after=None):
        super().__init__(f"{status} {code}: {message}")
        self.status = status
        self.code = code
        self.details = details
        self.retry_after = retry_after


def jelliu(method, path, body=None):
    res = requests.request(
        method,
        f"{BASE}{path}",
        headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
        json=body,
        timeout=60,
    )
    if res.status_code == 204:
        return None
    payload = res.json()
    if not res.ok:
        err = payload.get("error", {})
        raise JelliuError(
            res.status_code,
            err.get("code"),
            err.get("message"),
            err.get("details"),
            res.headers.get("Retry-After"),
        )
    return payload["data"]
```

#### Confirm you have a number and pick a voice

List the workspace's phone numbers and the voice catalog. Any reader key works for these two.

**`cURL`**

```bash title="cURL"
curl -sS "https://api.jelliu.co/api/phone-numbers" \
  -H "Authorization: Bearer $JELLIU_API_KEY"

curl -sS "https://api.jelliu.co/api/voices" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
import { jelliu } from './jelliu.js';

const numbers = await jelliu('GET', '/api/phone-numbers');
const active = numbers.filter((n) => n.isActive);
console.log('Active numbers:', active.map((n) => n.phoneNumber));

const { voices } = await jelliu('GET', '/api/voices');
const voice = voices.find((v) => v.accessible && v.category !== 'cloned') ?? voices[0];
console.log('Voice:', voice.id, voice.name);
```

**`Python`**

```python title="Python"
from jelliu import jelliu

numbers = jelliu("GET", "/api/phone-numbers")
active = [n for n in numbers if n["isActive"]]
print("Active numbers:", [n["phoneNumber"] for n in active])

voices = jelliu("GET", "/api/voices")["voices"]
voice = next((v for v in voices if v["accessible"] and v["category"] != "cloned"), voices[0])
print("Voice:", voice["id"], voice["name"])
```

Expected output:

```text
Active numbers: [ '+576015551234' ]
Voice: 21m00Tcm4TlvDq8ikWAM Rachel
```

Each voice has `id`, `name`, `category`, `gender`, `accent`, `language`, `accessible` and more. Pick one with `accessible: true`. If `Active numbers` is empty on a paid plan, get a number before continuing: without it the campaign will be paused on its first dial.

#### Create the agent

`name`, `voiceId` and `language` are required. `language` is one of `es`, `es-CO`, `es-MX`, `es-AR`, `es-neutral`, `en`, `en-US`, `pt`, `pt-BR`. `systemPrompt` is optional, but if you send one it must be at least 10 characters (up to 8,000). Write the prompt and first message in the language your contacts speak.

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/api/agents" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Renovaciones",
    "voiceId": "21m00Tcm4TlvDq8ikWAM",
    "language": "es-CO",
    "category": "sales",
    "systemPrompt": "Eres asesora de Seguros Andina. Llamas a clientes cuya póliza de auto vence este mes para ofrecer la renovación con 10% de descuento. Si aceptan, confirma su correo para enviar la propuesta.",
    "firstMessage": "Hola, te hablo de Seguros Andina. ¿Tienes un minuto para hablar de la renovación de tu póliza?"
  }'
```

**`Node.js`**

```javascript title="Node.js"
const agent = await jelliu('POST', '/api/agents', {
  name: 'Renovaciones',
  voiceId: voice.id,
  language: 'es-CO',
  category: 'sales',
  systemPrompt:
    'Eres asesora de Seguros Andina. Llamas a clientes cuya póliza de auto vence este mes ' +
    'para ofrecer la renovación con 10% de descuento. Si aceptan, confirma su correo para enviar la propuesta.',
  firstMessage: 'Hola, te hablo de Seguros Andina. ¿Tienes un minuto para hablar de la renovación de tu póliza?',
});
console.log('Agent:', agent.id, agent.channels);
```

**`Python`**

```python title="Python"
agent = jelliu("POST", "/api/agents", {
    "name": "Renovaciones",
    "voiceId": voice["id"],
    "language": "es-CO",
    "category": "sales",
    "systemPrompt": (
        "Eres asesora de Seguros Andina. Llamas a clientes cuya póliza de auto vence este mes "
        "para ofrecer la renovación con 10% de descuento. Si aceptan, confirma su correo para enviar la propuesta."
    ),
    "firstMessage": "Hola, te hablo de Seguros Andina. ¿Tienes un minuto para hablar de la renovación de tu póliza?",
})
print("Agent:", agent["id"], agent["channels"])
```

The response is `201` with the agent in `data`:

```json
{
  "data": {
    "id": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    "name": "Renovaciones",
    "language": "es-CO",
    "voice_id": "21m00Tcm4TlvDq8ikWAM",
    "category": "sales",
    "objective": "sales",
    "channels": ["voice", "whatsapp", "email", "webchat", "instagram", "messenger"],
    "total_calls": 0,
    "conversion_rate": 0,
    "avg_sentiment": null
  }
}
```

Omitting `channels` makes the agent serve every channel, which includes `voice`. If you send `channels`, it **replaces** the list, so include `voice`.

The agent is saved immediately and its voice runtime finishes provisioning in the background, usually within seconds. Calls attempted before it is ready fail with `AGENT_PROVISIONING` and the dialer retries them automatically, so you can continue straight away.

#### Subscribe to call results

Create the webhook **before** activating, so you do not miss the first results. Save `data.secret`: it is returned only once.

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/api/webhooks" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/jelliu",
    "events": ["call.completed", "call.failed", "campaign.completed"],
    "description": "Renewal campaign results"
  }'
```

**`Node.js`**

```javascript title="Node.js"
const webhook = await jelliu('POST', '/api/webhooks', {
  url: 'https://hooks.example.com/jelliu',
  events: ['call.completed', 'call.failed', 'campaign.completed'],
  description: 'Renewal campaign results',
});
console.log('Store this secret:', webhook.secret);
```

**`Python`**

```python title="Python"
webhook = jelliu("POST", "/api/webhooks", {
    "url": "https://hooks.example.com/jelliu",
    "events": ["call.completed", "call.failed", "campaign.completed"],
    "description": "Renewal campaign results",
})
print("Store this secret:", webhook["secret"])
```

Expected output:

```text
Store this secret: whsec_...
```

To receive only this campaign's events, add `"filters": { "campaignIds": ["CAMPAIGN_ID"] }` with `PATCH /api/webhooks/{webhookId}` once the campaign exists. See [Webhooks](/webhooks#filters).

#### Create the campaign

Required: `agentId`, `name`, `productContext` (10 to 5,000 characters), `targetAudience` (1 to 1,000) and `schedule`. The schedule is evaluated in its IANA `timezone`; each entry in `days` has `day`, `startHour` (0 to 23), `endHour` (1 to 24) and `enabled`, and at least one day must be enabled with `startHour` lower than `endHour`.

**`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": "Renovación de pólizas de auto que vencen en septiembre, con 10% de descuento si renuevan antes del día 30.",
    "targetAudience": "Clientes actuales con póliza de auto vigente",
    "channel": "voice",
    "category": "sales",
    "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": 18, "enabled": true }
      ]
    },
    "maxConcurrentCalls": 5,
    "maxRetryAttempts": 2,
    "retryIntervalMinutes": 120
  }'
```

**`Node.js`**

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

const campaign = await jelliu('POST', '/api/campaigns', {
  agentId: agent.id,
  name: 'Renovaciones septiembre',
  productContext:
    'Renovación de pólizas de auto que vencen en septiembre, con 10% de descuento si renuevan antes del día 30.',
  targetAudience: 'Clientes actuales con póliza de auto vigente',
  channel: 'voice',
  category: 'sales',
  schedule: {
    timezone: 'America/Bogota',
    days: weekdays.map((day) => ({ day, startHour: 9, endHour: 18, enabled: true })),
  },
  maxConcurrentCalls: 5,
  maxRetryAttempts: 2,
  retryIntervalMinutes: 120,
});
console.log('Campaign:', campaign.id, campaign.status);
```

**`Python`**

```python title="Python"
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday"]

campaign = jelliu("POST", "/api/campaigns", {
    "agentId": agent["id"],
    "name": "Renovaciones septiembre",
    "productContext": (
        "Renovación de pólizas de auto que vencen en septiembre, "
        "con 10% de descuento si renuevan antes del día 30."
    ),
    "targetAudience": "Clientes actuales con póliza de auto vigente",
    "channel": "voice",
    "category": "sales",
    "schedule": {
        "timezone": "America/Bogota",
        "days": [{"day": d, "startHour": 9, "endHour": 18, "enabled": True} for d in weekdays],
    },
    "maxConcurrentCalls": 5,
    "maxRetryAttempts": 2,
    "retryIntervalMinutes": 120,
})
print("Campaign:", campaign["id"], campaign["status"])
```

Expected output:

```text
Campaign: 0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90 draft
```

| Field                  | Default | Range                                                                                                                                   |
| ---------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `channel`              | `voice` | `voice`, `whatsapp`, `webchat`, `email`                                                                                                 |
| `category`             | `sales` | `sales`, `support`, `scheduling`, `surveys`, `collections`, `retention`, `notifications`, `interview`, `language_assessment`, `general` |
| `maxConcurrentCalls`   | 10      | 1 to 500, lowered to your plan's ceiling if higher                                                                                      |
| `maxRetryAttempts`     | 3       | 0 to 10                                                                                                                                 |
| `retryIntervalMinutes` | 60      | 5 to 1,440                                                                                                                              |

The campaign's name, `productContext` and `targetAudience` are passed to the agent on every call, together with your company name and the contact's name.

#### Import contacts

Send up to 5,000 contacts per request. Each needs at least one of `phoneNumber`, `email` or `whatsappNumber`; for a voice campaign use `phoneNumber` in E.164 format (`+` and country code). `metadata` takes up to 20 keys (letters, digits, `_` and `-`), each value up to 500 characters.

**`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": "+573001112233", "name": "Ana Gómez",  "metadata": { "policy_plan": "Auto Plus", "expires_on": "2026-09-28" } },
      { "phoneNumber": "+573004445566", "name": "Luis Pérez", "metadata": { "policy_plan": "Auto Básico", "expires_on": "2026-09-19" } },
      { "phoneNumber": "+573007778899", "name": "Marta Ruiz", "crmExternalId": "hs-48213", "crmProvider": "hubspot" }
    ]
  }'
```

**`Node.js`**

```javascript title="Node.js"
const contacts = [
  { phoneNumber: '+573001112233', name: 'Ana Gómez', metadata: { policy_plan: 'Auto Plus', expires_on: '2026-09-28' } },
  { phoneNumber: '+573004445566', name: 'Luis Pérez', metadata: { policy_plan: 'Auto Básico', expires_on: '2026-09-19' } },
  { phoneNumber: '+573007778899', name: 'Marta Ruiz', crmExternalId: 'hs-48213', crmProvider: 'hubspot' },
];

// Chunk large lists: 5,000 per request, 5 import requests per minute.
for (let i = 0; i < contacts.length; i += 5000) {
  const { imported } = await jelliu('POST', `/api/campaigns/${campaign.id}/contacts/bulk`, {
    contacts: contacts.slice(i, i + 5000),
  });
  console.log(`Imported ${imported} new contacts`);
}
```

**`Python`**

```python title="Python"
contacts = [
    {"phoneNumber": "+573001112233", "name": "Ana Gómez", "metadata": {"policy_plan": "Auto Plus", "expires_on": "2026-09-28"}},
    {"phoneNumber": "+573004445566", "name": "Luis Pérez", "metadata": {"policy_plan": "Auto Básico", "expires_on": "2026-09-19"}},
    {"phoneNumber": "+573007778899", "name": "Marta Ruiz", "crmExternalId": "hs-48213", "crmProvider": "hubspot"},
]

# Chunk large lists: 5,000 per request, 5 import requests per minute.
for i in range(0, len(contacts), 5000):
    result = jelliu("POST", f"/api/campaigns/{campaign['id']}/contacts/bulk", {
        "contacts": contacts[i:i + 5000],
    })
    print(f"Imported {result['imported']} new contacts")
```

Expected output:

```text
Imported 3 new contacts
```

`imported` counts only newly inserted rows. Duplicate phones inside the payload keep the first occurrence, phones already in the campaign are skipped, and previously deleted ones are restored. The whole batch counts against your plan's contact limit.

Metadata values are screened before they are stored: sequences that look like US Social Security numbers (including any 9-digit number) or card numbers (13 to 19 digits) are replaced with `[REDACTED-SSN]` or `[REDACTED-CARD]`. Do not rely on metadata to carry national ID or account numbers.

#### Run a webhook receiver

This receiver verifies `X-Webhook-Signature-V2`, acknowledges immediately, then fetches the full call. The signature is HMAC-SHA256 of the `X-Webhook-Timestamp` value, a period and the raw body, keyed with the whole secret including `whsec_`. [Webhooks](/webhooks#verifying-signatures) explains each step.

**`Node.js`**

```javascript title="Node.js"
// receiver.js  (npm install express)
import crypto from 'node:crypto';
import express from 'express';
import { jelliu } from './jelliu.js';

const SECRET = process.env.JELLIU_WEBHOOK_SECRET;
const TOLERANCE_MS = 10 * 60 * 1000;
const seen = new Set(); // use a database in production

const app = express();

app.post('/jelliu', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-Webhook-Signature-V2') ?? '';
  const timestamp = req.get('X-Webhook-Timestamp') ?? '';
  const rawBody = req.body.toString('utf8');

  const sentAt = Date.parse(timestamp);
  if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > TOLERANCE_MS) {
    return res.status(400).send('stale');
  }
  const expected = crypto.createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('bad signature');
  }

  res.sendStatus(200);
  if (seen.has(signature)) return; // retries carry the same signature
  seen.add(signature);

  const event = JSON.parse(rawBody);
  handle(event).catch((err) => console.error('handler failed', err));
});

async function handle(event) {
  const d = event.data;
  switch (event.event) {
    case 'call.completed': {
      console.log(`[completed] ${d.phoneNumber} outcome=${d.outcome} duration=${d.duration}s`);
      console.log(`  summary: ${d.summary}`);
      const call = await jelliu('GET', `/api/calls/${d.callId}`);
      console.log('  collected:', JSON.stringify(call.data_collection_results));
      break;
    }
    case 'call.failed':
      console.log(`[failed] ${d.phoneNumber} outcome=${d.outcome}`);
      break;
    case 'campaign.completed':
      console.log(`[campaign done] ${d.campaignName} (${d.campaignId})`);
      break;
  }
}

app.listen(3000, () => console.log('Listening on :3000'));
```

**`Python`**

```python title="Python"
# receiver.py  (pip install flask requests)
import hashlib
import hmac
import json
import os
import threading
from datetime import datetime, timezone

from flask import Flask, abort, request

from jelliu import jelliu

SECRET = os.environ["JELLIU_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 10 * 60
seen = set()  # use a database in production

app = Flask(__name__)


def handle(event):
    d = event["data"]
    if event["event"] == "call.completed":
        print(f"[completed] {d['phoneNumber']} outcome={d['outcome']} duration={d['duration']}s")
        print(f"  summary: {d['summary']}")
        call = jelliu("GET", f"/api/calls/{d['callId']}")
        print("  collected:", json.dumps(call.get("data_collection_results")))
    elif event["event"] == "call.failed":
        print(f"[failed] {d['phoneNumber']} outcome={d.get('outcome')}")
    elif event["event"] == "campaign.completed":
        print(f"[campaign done] {d['campaignName']} ({d['campaignId']})")


@app.post("/jelliu")
def jelliu_webhook():
    signature = request.headers.get("X-Webhook-Signature-V2", "")
    timestamp = request.headers.get("X-Webhook-Timestamp", "")
    raw_body = request.get_data()

    try:
        sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    except ValueError:
        abort(400)
    if abs((datetime.now(timezone.utc) - sent_at).total_seconds()) > TOLERANCE_SECONDS:
        abort(400)

    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)

    if signature not in seen:  # retries carry the same signature
        seen.add(signature)
        threading.Thread(target=handle, args=(json.loads(raw_body),)).start()
    return "", 200


if __name__ == "__main__":
    app.run(port=3000)
```

Start it with `JELLIU_WEBHOOK_SECRET=whsec_... node receiver.js` (or `python receiver.py`) and point your tunnel at port 3000.

#### Activate the campaign

Activation checks the campaign, then enqueues every `pending` contact. It is idempotent: activating an already active campaign returns it unchanged.

**`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 activated = await jelliu('PATCH', `/api/campaigns/${campaign.id}/activate`);
console.log('Status:', activated.status);
```

**`Python`**

```python title="Python"
activated = jelliu("PATCH", f"/api/campaigns/{campaign['id']}/activate")
print("Status:", activated["status"])
```

Expected output:

```text
Status: active
```

Calls start at the next moment inside the schedule. Outside it, contacts wait and are dialed when the next window opens. To stop new calls from starting, `PATCH /api/campaigns/{campaignId}/pause`, and activate it again to resume.

#### Receive results

As calls end, your receiver prints lines like:

```text
[completed] +573001112233 outcome=callback_scheduled duration=184s
  summary: La clienta pidió que la llamen el jueves en la tarde para revisar la propuesta.
  collected: {"email":{"value":"ana@example.com","rationale":"La clienta dictó su correo."}}
[failed] +573004445566 outcome=failed
[campaign done] Renovaciones septiembre (0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90)
```

The `call.completed` body:

```json
{
  "event": "call.completed",
  "timestamp": "2026-09-15T15:42:07.318Z",
  "data": {
    "callId": "5b0d2f7e-9a41-4c3e-8f0a-2c6d1e7b9a10",
    "campaignId": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
    "contactId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    "phoneNumber": "+573001112233",
    "outcome": "callback_scheduled",
    "sentimentScore": 0.6,
    "duration": 184,
    "summary": "La clienta pidió que la llamen el jueves en la tarde para revisar la propuesta.",
    "dataCollection": { "email": { "value": "ana@example.com", "rationale": "La clienta dictó su correo." } },
    "kpiData": null
  }
}
```

| Field                       | Notes                                                                                                                                                                                                                          |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `outcome`                   | The call's result for its category, for example `sale_closed`, `callback_scheduled`, `rejected`, `no_answer`, `voicemail`, `appointment_booked`, `payment_promised`. Can be `null` when the analysis reached no clear verdict. |
| `sentimentScore`            | -1 to 1. Sent as `0` when no sentiment was measured, so do not read `0` as "neutral" without checking the call.                                                                                                                |
| `duration`                  | Seconds, or `null`.                                                                                                                                                                                                            |
| `summary`, `dataCollection` | From the post-call analysis. `dataCollection` maps each field to `value` and `rationale`.                                                                                                                                      |
| `kpiData`                   | Campaign KPI values when they apply, otherwise `null`.                                                                                                                                                                         |

`call.failed` is sent instead when the call failed technically or never connected. When the analysis is still pending as the call ends, Jelliu waits and sends the event once the real outcome is known, so events can arrive some time after hang-up. Treat every field as optional.

#### Read results from the API

Webhooks are the fast path; the API is the source of truth for reconciliation and reporting.

**`cURL`**

```bash title="cURL"
curl -sS "https://api.jelliu.co/api/calls?campaignId=0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90&limit=100" \
  -H "Authorization: Bearer $JELLIU_API_KEY"

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

**`Node.js`**

```javascript title="Node.js"
// All calls of the campaign, following the cursor.
const calls = [];
let cursor = null;
do {
  const qs = new URLSearchParams({ campaignId: campaign.id, limit: '100' });
  if (cursor) qs.set('cursor', cursor);
  const page = await jelliu('GET', `/api/calls?${qs}`);
  calls.push(...page.calls);
  cursor = page.nextCursor;
} while (cursor);

const byOutcome = {};
for (const c of calls) byOutcome[c.outcome ?? 'no verdict'] = (byOutcome[c.outcome ?? 'no verdict'] ?? 0) + 1;
console.log(`${calls.length} calls`, byOutcome);

const pending = await jelliu('GET', `/api/campaigns/${campaign.id}/contacts?status=pending&limit=1`);
console.log('Contacts still pending:', pending.total);
```

**`Python`**

```python title="Python"
from collections import Counter

calls, cursor = [], None
while True:
    params = f"campaignId={campaign['id']}&limit=100" + (f"&cursor={cursor}" if cursor else "")
    page = jelliu("GET", f"/api/calls?{params}")
    calls.extend(page["calls"])
    cursor = page["nextCursor"]
    if not cursor:
        break

by_outcome = Counter(c["outcome"] or "no verdict" for c in calls)
print(f"{len(calls)} calls", dict(by_outcome))

pending = jelliu("GET", f"/api/campaigns/{campaign['id']}/contacts?status=pending&limit=1")
print("Contacts still pending:", pending["total"])
```

Expected output:

```text
3 calls { callback_scheduled: 1, failed: 1, no_answer: 1 }
Contacts still pending: 0
```

`GET /api/calls` returns `data` with `calls`, `total`, `limit`, `offset` and `nextCursor`. Filter with `agentId` or `campaignId`; `limit` is 1 to 100 (default 50). Each call includes `id`, `campaign_id`, `contact_id`, `agent_id`, `phone_number`, `status` (`queued`, `ringing`, `in-progress`, `completed`, `failed`, `no-answer`, `busy`, `canceled`), `outcome`, `duration_seconds`, `summary`, `sentiment_score`, `evaluation_criteria_results`, `data_collection_results`, `started_at`, `ended_at`, an `analysis` object (`summary`, `outcome`, `sentiment`, `sentiment_score`, `key_topics`, `qualification`) and `has_recording`. `GET /api/calls/{callId}` returns a single call with its transcript. See [Calls](/resources/calls).

Campaign contacts have a `status` of `pending`, `called`, `converted`, `failed`, `dnc` or `invalid`. See [Contacts](/resources/contacts).

## What happens while the campaign runs

| Situation                                                                                                            | What Jelliu does                                                                                                                                                  |
| -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Outside the campaign schedule                                                                                        | The dial is postponed to the next enabled window in the schedule's timezone. If the schedule has no upcoming window at all, the contact is closed without a call. |
| Workspace concurrency limit reached                                                                                  | The dial is postponed and retried; the contact stays `pending`.                                                                                                   |
| Contact refused by compliance rules (opt-out, suppression list, blocked prefix, the country's allowed calling hours) | No call. The contact is marked `dnc` and is not retried. Keep your schedule inside the destination country's allowed hours.                                       |
| Agent still provisioning                                                                                             | The dial is retried automatically.                                                                                                                                |
| No answer, busy, or the call could not connect                                                                       | Recorded on the call. The contact is retried up to `maxRetryAttempts` times, `retryIntervalMinutes` apart.                                                        |
| Every contact reaches a final state                                                                                  | The campaign becomes `completed` and `campaign.completed` is sent.                                                                                                |

### When Jelliu pauses a campaign

Some problems belong to the workspace, not to a contact, and no retry can fix them. In those cases the dialer pauses the whole campaign, leaves the contact `pending` and records the reason in the campaign's `blocked_reason`, which `GET /api/campaigns/{campaignId}` returns:

| Cause                                            | `blocked_reason`                                                                                                           |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| The workspace has no phone number of its own     | `Falta un número de teléfono propio. Añade uno en Ajustes → Números (compra uno o conecta el tuyo) y reactiva la campaña.` |
| The campaign's agent was paused in the dashboard | `El agente de esta campaña está detenido. Reanúdalo desde Agentes y reactiva la campaña.`                                  |

Fix the cause, then call `PATCH /api/campaigns/{campaignId}/activate` again. Dialing resumes with the contacts that are still `pending`.

## Errors

| Step                                             | Status | Code                  | Message or cause                                                                                                                       |
| ------------------------------------------------ | ------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Any                                              | `401`  | `UNAUTHORIZED`        | Missing, revoked or expired key.                                                                                                       |
| Create campaign, activate, pause, create webhook | `403`  | `FORBIDDEN`           | `This operation requires an API key with the 'full' scope`                                                                             |
| Create agent                                     | `403`  | `BILLING_ERROR`       | Agent limit of the plan reached.                                                                                                       |
| Create agent                                     | `400`  | `VALIDATION_FAILED`   | Missing `voiceId` or `language`, or a `systemPrompt` of 1 to 9 characters.                                                             |
| Create campaign                                  | `400`  | `VALIDATION_FAILED`   | `Invalid campaign input`, with `details.fieldErrors` (for example `productContext` shorter than 10 characters or an invalid timezone). |
| Create campaign                                  | `404`  | `AGENT_NOT_FOUND`     | `Agent not found or does not belong to this tenant`                                                                                    |
| Create campaign                                  | `403`  | `BILLING_ERROR`       | Campaign limit of the plan reached.                                                                                                    |
| Import contacts                                  | `400`  | `VALIDATION_FAILED`   | `Invalid bulk contacts input`: a phone not in E.164, a contact with no contact method, or more than 5,000 contacts.                    |
| Import contacts                                  | `403`  | `BILLING_ERROR`       | The batch would exceed the plan's contact limit.                                                                                       |
| Activate                                         | `400`  | `VALIDATION_FAILED`   | `Campaign has no pending contacts to call`, or more pending contacts than the plan allows.                                             |
| Activate                                         | `400`  | `CAMPAIGN_NOT_ACTIVE` | `Campaign can only be activated from DRAFT or PAUSED status`                                                                           |
| Activate                                         | `403`  | `BILLING_ERROR`       | Active campaign limit of the plan reached. Pause or complete another campaign.                                                         |
| Activate                                         | `409`  | `CAMPAIGN_NOT_ACTIVE` | `Campaign status changed concurrently`                                                                                                 |
| Pause                                            | `400`  | `CAMPAIGN_NOT_ACTIVE` | `Campaign can only be paused when ACTIVE`                                                                                              |
| Any mutation                                     | `429`  | `RATE_LIMIT_EXCEEDED` | See Limits. Wait for `Retry-After`.                                                                                                    |

`BILLING_ERROR` responses include `metadata` with `limit`, `current` and `tier`. See [Errors](/errors).

## Limits

| Limit                                                    | Value                                                                                          |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Agent, campaign, activation, pause and webhook mutations | 10 per minute per workspace, one shared budget                                                 |
| Requests under `/api/campaigns/{campaignId}/contacts`    | 5 per minute per workspace                                                                     |
| Requests under `/api/calls`                              | 20 per minute per workspace                                                                    |
| Contacts per bulk request                                | 5,000                                                                                          |
| Concurrent calls per campaign                            | `maxConcurrentCalls`, capped by your plan                                                      |
| Campaigns, active campaigns, contacts, agents            | Per plan. See [Billing and usage](/platform/billing-and-usage).                                |
| Scope                                                    | `full` for campaigns and webhooks; `write` for agents and contacts; `read` for reading results |

## Troubleshooting

#### Activation succeeded but no calls are happening

Check, in order: the current time in the schedule's `timezone` is inside an enabled day and hour range; `GET /api/campaigns/{campaignId}` shows `status: active` and no `blocked_reason`; `GET /api/campaigns/{campaignId}/contacts?status=pending` still has contacts. If the campaign flipped to `paused` with a `blocked_reason`, fix what it says and activate again.

#### Contacts end up as dnc without a call

The compliance check refused them: the number is on the suppression list or opted out, matches a blocked prefix, or the dial fell outside the destination country's allowed calling hours. Review **Settings → Compliance** and align the campaign schedule with those hours. See [Compliance](/platform/compliance).

#### Every call fails with PHONE\_NUMBER\_REQUIRED, or the campaign pauses immediately

The workspace is on a paid plan without a phone number of its own. Provision or connect one (see [Phone numbers](/resources/phone-numbers)), wait until it is active, then activate the campaign again.

#### No webhooks arrive

Open `GET /api/webhooks/{webhookId}/delivery-logs`. `status: 0` means Jelliu got no HTTP response (tunnel down, TLS problem, timeout over 10 seconds). A `4xx` is not retried, so check your signature code. After 10 consecutive failures the webhook is disabled; re-enable it with `PATCH /api/webhooks/{webhookId}` and `{ "is_active": true }`.

#### Signature verification fails

Verify against the **raw** body, not re-serialized JSON, and include the `whsec_` prefix in the key. Use `X-Webhook-Signature-V2` with the `X-Webhook-Timestamp` value exactly as received.

#### The same event arrives twice

Deliveries are retried on `5xx`, timeouts and network errors. A retry carries the same timestamp and body, so its signature is identical: deduplicate on it, as the receiver above does.

#### outcome is null on call.completed

The analysis reached no decisive verdict for that call. Read `summary` and `dataCollection`, or fetch the call and its transcript with `GET /api/calls/{callId}`.

## Related

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

Every campaign field and endpoint.

#### [Webhooks](/webhooks)

Event catalog, signatures, retries and filters.

#### [CRM sync with webhooks](/recipes/crm-sync-with-webhooks)

Idempotent processing and writing outcomes to a CRM.

#### [Compliance](/platform/compliance)

Opt-outs, suppression lists and calling hours.