Run an outbound voice campaign

Create an agent, build a campaign, import contacts, activate it, receive call.completed webhooks and read the results, end to end.
View as Markdown

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

A campaign moves through these statuses:

StatusMeaning
draftCreated, not calling. You can add contacts and edit it.
activeThe dialer is working through pending contacts inside the schedule.
pausedNo new calls start. Set by you, or by Jelliu when the workspace cannot dial (see When Jelliu pauses a campaign).
completedEvery contact reached a final state. campaign.completed is sent.
archivedRetired. Cannot be activated or edited.

Prerequisites

1

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.

export JELLIU_API_KEY="jl_..."
2

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.

3

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 covers tunnels and idempotent processing in depth.

Build it

1

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.

// 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;
}
2

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

Expected output:

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.

3

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 -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?"
}'

The response is 201 with the agent in data:

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

4

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

Expected output:

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.

5

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 -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
}'

Expected output:

Campaign: 0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90 draft
FieldDefaultRange
channelvoicevoice, whatsapp, webchat, email
categorysalessales, support, scheduling, surveys, collections, retention, notifications, interview, language_assessment, general
maxConcurrentCalls101 to 500, lowered to your plan’s ceiling if higher
maxRetryAttempts30 to 10
retryIntervalMinutes605 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.

6

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 -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" }
]
}'

Expected output:

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.

7

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 explains each step.

// 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'));

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

8

Activate the campaign

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

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

Expected output:

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.

9

Receive results

As calls end, your receiver prints lines like:

[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:

{
"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
}
}
FieldNotes
outcomeThe 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.
durationSeconds, or null.
summary, dataCollectionFrom the post-call analysis. dataCollection maps each field to value and rationale.
kpiDataCampaign 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.

10

Read results from the API

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

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"

Expected output:

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.

Campaign contacts have a status of pending, called, converted, failed, dnc or invalid. See Contacts.

What happens while the campaign runs

SituationWhat Jelliu does
Outside the campaign scheduleThe 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 reachedThe 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 provisioningThe dial is retried automatically.
No answer, busy, or the call could not connectRecorded on the call. The contact is retried up to maxRetryAttempts times, retryIntervalMinutes apart.
Every contact reaches a final stateThe 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:

Causeblocked_reason
The workspace has no phone number of its ownFalta 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 dashboardEl 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

StepStatusCodeMessage or cause
Any401UNAUTHORIZEDMissing, revoked or expired key.
Create campaign, activate, pause, create webhook403FORBIDDENThis operation requires an API key with the 'full' scope
Create agent403BILLING_ERRORAgent limit of the plan reached.
Create agent400VALIDATION_FAILEDMissing voiceId or language, or a systemPrompt of 1 to 9 characters.
Create campaign400VALIDATION_FAILEDInvalid campaign input, with details.fieldErrors (for example productContext shorter than 10 characters or an invalid timezone).
Create campaign404AGENT_NOT_FOUNDAgent not found or does not belong to this tenant
Create campaign403BILLING_ERRORCampaign limit of the plan reached.
Import contacts400VALIDATION_FAILEDInvalid bulk contacts input: a phone not in E.164, a contact with no contact method, or more than 5,000 contacts.
Import contacts403BILLING_ERRORThe batch would exceed the plan’s contact limit.
Activate400VALIDATION_FAILEDCampaign has no pending contacts to call, or more pending contacts than the plan allows.
Activate400CAMPAIGN_NOT_ACTIVECampaign can only be activated from DRAFT or PAUSED status
Activate403BILLING_ERRORActive campaign limit of the plan reached. Pause or complete another campaign.
Activate409CAMPAIGN_NOT_ACTIVECampaign status changed concurrently
Pause400CAMPAIGN_NOT_ACTIVECampaign can only be paused when ACTIVE
Any mutation429RATE_LIMIT_EXCEEDEDSee Limits. Wait for Retry-After.

BILLING_ERROR responses include metadata with limit, current and tier. See Errors.

Limits

LimitValue
Agent, campaign, activation, pause and webhook mutations10 per minute per workspace, one shared budget
Requests under /api/campaigns/{campaignId}/contacts5 per minute per workspace
Requests under /api/calls20 per minute per workspace
Contacts per bulk request5,000
Concurrent calls per campaignmaxConcurrentCalls, capped by your plan
Campaigns, active campaigns, contacts, agentsPer plan. See Billing and usage.
Scopefull for campaigns and webhooks; write for agents and contacts; read for reading results

Troubleshooting

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.

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.

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

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

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.

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.

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