Skip to content
Guide

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 · 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?

  1. 01Write the caller

    The agent is the script. Merge fields make each call personal:

    agent.mjs
    import { 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's fields becomes 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_id is required for campaigns: it is the caller ID every call presents.

  2. 02Upload the leads

    The rest of the build is one script, campaign.mjs. It starts with the upload:

    campaign.mjs, part 1
    import { 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_count and errors rather than assuming the whole list landed. A bulk upload fires no webhooks.

  3. 03Run campaigns sized to your concurrency limit

    A campaign is one agent and a list of leads. Creating one dials nobody; start does, 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 2
    const 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);
      }
    }

    start answers with the campaign at status: "running". To stop a batch early, client.campaigns.pause(campaign.id) stops it and hangs up its calls in flight, after which the campaign reads status: "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_at on create starts a campaign at a set time instead of on start.

  4. 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 3
    for (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);
      }
    }

    status says whether the call connected, failure_code why it did not (17 busy, 19 no answer, 21 rejected by the carrier) and cost_micros what it cost. A lead refused before dialling, at the concurrency limit or for lack of credit, shows as failed with no failure_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 summary field may be empty, so do not depend on it. To react as calls end instead of polling, subscribe to the callCompleted webhook, which carries the lead and campaign ids with the call's status and duration.

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

Monthly engine cost for this guide’s build
Calls per monthConnected minutesEngine cost per month
1,0002,000$173
5,00010,000$867
20,00040,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-Version header.

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, and POST /v1/campaigns/{id}/start has 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 by lead_id. Single calls go through POST /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/calls per lead from your own queue gives finer control: each call takes a lead_id and 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.

APIs for agentic telephony

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.