Run an AI lead-qualification campaign with Wixzel Voice
At the end you will have uploaded a list of leads, had an agent you wrote call each one over your own SIP trunk with your qualifying questions, and read back every call's status and transcript. The build takes about 30 minutes in Node.js with the wixzel-voice SDK, plus the time the calls take. Short scripted calls are the cheapest workload: on the classic engine, $0.0867 per connected minute, 1,000 two-minute calls come to about $173 in engine time, billed per second from a prepaid balance, plus your carrier's rate. A call nobody answers costs nothing.
By Aqeel Shamsudheen · Published · Updated
- Takes
- About 30 minutes, plus the calls
- Engine
- classic
- Engine time
- $0.0867 per connected minute
What do you need before you start?
- A SIP trunk and an outbound number registered on it. The Twilio guide and the SIP trunks docs cover both.
- A Wixzel Voice API key with credit, and Node.js 20 or later with
npm install wixzel-voice. - Consent to call every lead on the list where the law requires it. The compliance step below summarises what the Acceptable Use Policy asks.
How do you build this on Wixzel Voice, step by step?
01Write the caller
The agent is the script. Merge fields make each call personal:
agent.mjsimport { WixzelVoice } from 'wixzel-voice'; const client = new WixzelVoice({ apiKey: process.env.WIXZEL_API_KEY }); const agent = await client.agents.create({ name: 'Demo follow-up', outbound_phone_number_id: process.env.NUMBER_ID, system_prompt: [ 'You are calling {{name}} from {{company}}, who asked for a demo of Acme Scheduling.', 'Ask three questions, one at a time: how many staff take bookings,', 'what they use for bookings today, and when they would want to switch.', 'Keep replies short. If they are not interested, thank them and end the call.', 'If they ask not to be called again, confirm that you have noted it and end the call.', 'Finish by thanking them and saying a person will follow up by email.', ].join('\n'), opening_message: 'Hi {{name}}, this is an automated assistant calling for Acme about the demo you requested. Is now a good time?', voice: { stt: { model: 'deepgram/nova-3', language: 'en-US' }, llm: { model: 'openrouter/gpt-4o-mini' }, tts: { model: 'elevenlabs/eleven_turbo_v2_5' }, }, }); console.log('AGENT_ID', agent.id);{{name}}resolves from the lead, and every key in a lead'sfieldsbecomes a merge field too, so{{company}}comes from the upload below. The opening line says the caller is automated, which is where that disclosure belongs when the law requires it.outbound_phone_number_idis required for campaigns: it is the caller ID every call presents.02Upload the leads
The rest of the build is one script,
campaign.mjs. It starts with the upload:campaign.mjs, part 1import { WixzelVoice } from 'wixzel-voice'; const client = new WixzelVoice({ apiKey: process.env.WIXZEL_API_KEY }); // From your CRM or a CSV: only people you have consent to call. const rows = [ { name: 'Priya Nair', phone_number: '+14155550111', fields: { company: 'Northside Physio' } }, { name: 'Sam Carter', phone_number: '+14155550112', fields: { company: 'Carter Auto' } }, ]; const upload = await client.leads.bulkCreate({ leads: rows }); console.log('created', upload.created_count, 'failed', upload.failed_count); for (const e of upload.errors) console.log('row', e.index, e.phone_number, e.error); const leadIds = upload.data.map((lead) => lead.id);Up to 1,000 leads go in one request. Rows are validated one by one, so check
failed_countanderrorsrather than assuming the whole list landed. A bulk upload fires no webhooks.03Run campaigns sized to your concurrency limit
A campaign is one agent and a list of leads. Creating one dials nobody;
startdoes, and spends credit as each call connects.In the alpha a campaign places its calls in quick succession rather than queueing them, and each call counts toward the account's concurrent-call limit, five by default. A lead beyond the limit is recorded as a failed call instead of waiting its turn. So run the list in batches no larger than your limit, and start the next batch when the last one has finished:
campaign.mjs, part 2const LIMIT = 5; // your account's concurrent-call limit, minus any calls you expect meanwhile const DONE = new Set(['completed', 'failed', 'busy', 'no-answer', 'canceled']); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function finishedCalls(campaignId) { let finished = 0; for await (const call of await client.calls.list({ campaign_id: campaignId, limit: 100 })) { if (DONE.has(call.status)) finished++; } return finished; } const campaignIds = []; for (let i = 0; i < leadIds.length; i += LIMIT) { const batch = leadIds.slice(i, i + LIMIT); const campaign = await client.campaigns.create({ name: 'Demo follow-up ' + (i / LIMIT + 1), agent_id: process.env.AGENT_ID, lead_ids: batch, }); await client.campaigns.start(campaign.id); campaignIds.push(campaign.id); const giveUpAt = Date.now() + 30 * 60 * 1000; while ((await finishedCalls(campaign.id)) < batch.length && Date.now() < giveUpAt) { await sleep(15000); } }startanswers with the campaign atstatus: "running". To stop a batch early,client.campaigns.pause(campaign.id)stops it and hangs up its calls in flight, after which the campaign readsstatus: "stopped". Starting a stopped campaign again dials every lead on it again, including the ones already called, so to carry on, create a new campaign from the leads not yet reached.scheduled_aton create starts a campaign at a set time instead of onstart.04Read every call back
Each call is a record with its status, cost and transcript, linked to its lead by
lead_id:campaign.mjs, part 3for (const campaignId of campaignIds) { for await (const call of await client.calls.list({ campaign_id: campaignId, limit: 100 })) { const detail = await client.calls.retrieve(call.id); const answers = detail.transcript .filter((line) => line.role === 'user') .map((line) => line.content) .join(' / '); console.log(call.lead_id, call.status, call.duration_seconds, call.failure_code, answers); } }statussays whether the call connected,failure_codewhy it did not (17 busy, 19 no answer, 21 rejected by the carrier) andcost_microswhat it cost. A lead refused before dialling, at the concurrency limit or for lack of credit, shows asfailedwith nofailure_code.The qualification itself is your decision. Wixzel Voice records the conversation; the transcript holds the answers to your three questions, so score it with your own rules or your own model and write the result to your CRM. The
summaryfield may be empty, so do not depend on it. To react as calls end instead of polling, subscribe to thecallCompletedwebhook, which carries the lead and campaign ids with the call's status and duration.05Stay within calling rules
The Acceptable Use Policy puts these on the caller, which is you. This is a summary of it, not legal advice.
Call only people whose consent you have where the law requires it. In the United States that generally means prior express written consent for marketing calls to mobile numbers under the TCPA; in India, the TRAI customer preference regulations; in the UK and EU, PECR and the GDPR.
Check the national and state do-not-call registries and your own suppression list before each upload. When someone asks not to be called again, remove them promptly and permanently: the agent can end the call politely, but removing the lead is your code's job.
Say that the caller is automated at the start of the call where the law requires it, as the opening line above does, respect calling-hour restrictions, and keep proof of consent for as long as the law requires.
What does this build cost on Wixzel Voice?
Engine time for 2-minute calls on the classic engine at $0.0867 per connected minute: about $0.17 per call. Telephony is your own trunk at your carrier’s rate and is not included.
| Calls per month | Connected minutes | Engine cost per month |
|---|---|---|
| 1,000 | 2,000 | $173 |
| 5,000 | 10,000 | $867 |
| 20,000 | 40,000 | $3,468 |
Only connected time is billed, per second. A call that is busy, unanswered or refused before dialling costs nothing, because the engine never starts. Your carrier bills the line separately at its own rates, with no markup.
Prepaid, with no subscription and no free tier: add credit from $5 and usage spends it. Estimate your own mix.
Which Wixzel Voice limits apply to this build?
- A campaign dials its leads in quick succession and does not queue behind the concurrent-call limit, five per account by default; leads beyond it are recorded as failed. Batch to your limit, or ask for a higher one.
- Starting a stopped campaign dials every lead on it again. Continue with a new campaign made of the leads not yet reached.
- Each campaign holds up to 10,000 leads, and an account holds up to 500 campaigns by default, raised on request.
- Wixzel Voice records the call and its transcript. Deciding who qualified, and keeping your do-not-call list, is your code's job.
- Wixzel Voice is in alpha. Behavioural changes ship behind a dated
Wixzel-Versionheader.
Frequently asked questions
- How do I run an outbound AI calling campaign on Wixzel Voice?
- Upload leads with
POST /v1/leads/bulk, create a campaign with an agent and those leads, andPOST /v1/campaigns/{id}/starthas the agent call each one over your own SIP trunk, back to back. Keep each campaign no larger than your concurrent-call limit, because a lead over it is marked failed rather than queued. Every call is a record with its status, transcript and cost, linked to the lead bylead_id. Single calls go throughPOST /v1/calls. - How many outbound calls can run at once?
- Five per account by default, counting every live call and web session. The limit is raised on request: write to aqeel@wixzel.com with the volume you expect.
- Should I use campaigns or single calls?
- Campaigns suit a list you call in one go. When you want your own pacing and retries,
POST /v1/callsper lead from your own queue gives finer control: each call takes alead_idand an idempotency key, and you decide when the next one starts. - Is AI outbound calling legal?
- It depends on where the people you call are and on their consent. The Acceptable Use Policy requires consent where the law requires it, honouring do-not-call registries and opt-outs, and disclosing that the caller is automated where required. Wixzel Voice provides the tooling, not the legal basis.
Where to read more
- Quickstartdocs
A trunk, a number, an agent and one call.
- Agentsdocs
Prompts, opening lines and caller IDs.
- Webhooksdocs
callCompleted, campaignCompleted and signatures.
- API referencedocs
Leads, campaigns and calls, field by field.
About Wixzel Voice
Wixzel Voice is a voice AI API for building AI agents that place and answer real phone calls over your own SIP trunk, or talk to people in your web and mobile apps. One API key and one prepaid balance cover every voice engine, billed per second of actual usage.
More guides
Claude, in Claude Code or claude.ai, placing a real call over your SIP trunk and reading back the transcript, with a confirmation before anything dials.
Your existing Twilio number answered by an AI receptionist that books appointments into the API, built in Node.js.
Two phone agents, one speaking Hindi and one Malayalam, on Sarvam's Indian-language models, answering or placing calls over your SIP trunk.
A Flutter app where users talk to your voice agent over a WebSocket, with the API key kept on your server and no phone number involved.
A Gemini Live agent ringing a real phone over your SIP trunk from four cURL requests, and the call record with its transcript and cost.
