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
A campaign moves through these statuses:
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.
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.
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
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.
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.
Expected output:
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.
The response is 201 with the agent in data:
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.
Expected output:
To receive only this campaign’s events, add "filters": { "campaignIds": ["CAMPAIGN_ID"] } with PATCH /api/webhooks/{webhookId} once the campaign exists. See Webhooks.
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.
Expected output:
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.
Expected output:
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 explains each step.
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.
Expected output:
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:
The call.completed body:
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.
Expected output:
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
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:
Fix the cause, then call PATCH /api/campaigns/{campaignId}/activate again. Dialing resumes with the contacts that are still pending.
Errors
BILLING_ERROR responses include metadata with limit, current and tier. See Errors.
Limits
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.
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), 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}.

