---
title: Set Up a Progressive Dialer Campaign
description: >-
  Create a progressive dialer campaign with the SautiKit API, join agents by
  extension, and put every answered call through to a free agent.
summary: >-
  A progressive campaign dials one contact for each free agent and connects
  whoever answers straight to that agent. Create it, add contacts, join agents,
  start.
date: 2026-10-28T00:00:00.000Z
type: guide
---


## Summary

A progressive dialer calls one contact for each agent who is free, and puts the person who answers straight through to that agent. No agent sits through ringing, voicemail or a busy tone, and no caller who answers is left waiting for an agent, because a call is only placed when someone is free to take it.

Use progressive dialing for collections, sales follow-ups, renewals and appointment confirmations: any list where a person should speak to each contact who picks up. If your team is large and your list answers rarely, [predictive dialing](/developers/guides/run-a-predictive-dialer-campaign) keeps agents busier by dialing ahead.

This guide covers the order of the calls. Every field, error and response is in the [API reference](/developers/api#campaigns).

You can also set up the same campaign in SautiDesk without writing code: pick **Progressive** in the campaign setup and assign a queue or people.

## Prerequisites

- An API key with `dialer.write` and `dialer.read`. Joining an extension that belongs to a person also needs `dialer.supervise`. [Create a key](https://app.sautikit.com/developers/api-keys).
- A plan that includes progressive dialing (SautiDesk Pro). Without it, create and start answer `402 plan.required`.
- One or more numbers on your workspace to call from.
- An extension for each agent: a SIP phone, a softphone, or a browser client built with the [WebRTC SDK](/developers/guides/browser-calling-with-webrtc).
- A funded [wallet](/developers/concepts/wallet). Starting a campaign spends wallet money.

---

## Steps

### Step 1: Create the campaign with `mode: progressive`

[Create a dialer campaign](/developers/api/createDialerCampaign) with `mode` set to `progressive`. You do not need a `callback_url`: answered calls go to an agent, not to your server. Turn on answering-machine detection so that voicemail is never put through to an agent.

**JavaScript**

```javascript
const BASE = "https://api.sautikit.com";
const headers = {
  Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
  "Content-Type": "application/json",
};

const campaign = await fetch(`${BASE}/v1/dialer/campaigns`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    name: "November renewals",
    mode: "progressive",
    caller_ids: ["+254709000001", "+254709000002"],
    budget_minor: 1000000,
    hours: {
      timezone: "Africa/Nairobi",
      windows: [{ days: [1, 2, 3, 4, 5], start_minute: 540, end_minute: 1020 }],
    },
    retry: { max_attempts: 3, backoff_minutes: [60, 240, 1440], retry_on: ["no_answer", "busy"] },
    amd: { mode: "detect_and_act", on_machine: "hangup", max_analysis_seconds: 4 },
  }),
}).then((r) => r.json());

console.log(campaign.id, campaign.mode, campaign.status); // progressive draft
```

**Python**

```python
import os, requests

BASE = "https://api.sautikit.com"
headers = {"Authorization": f"Bearer {os.environ['SAUTIKIT_API_KEY']}"}

campaign = requests.post(
    f"{BASE}/v1/dialer/campaigns",
    headers=headers,
    json={
        "name": "November renewals",
        "mode": "progressive",
        "caller_ids": ["+254709000001", "+254709000002"],
        "budget_minor": 1000000,
        "hours": {
            "timezone": "Africa/Nairobi",
            "windows": [{"days": [1, 2, 3, 4, 5], "start_minute": 540, "end_minute": 1020}],
        },
        "retry": {"max_attempts": 3, "backoff_minutes": [60, 240, 1440], "retry_on": ["no_answer", "busy"]},
        "amd": {"mode": "detect_and_act", "on_machine": "hangup", "max_analysis_seconds": 4},
    },
).json()

print(campaign["id"], campaign["mode"], campaign["status"])  # progressive draft
```

With more than one caller ID, each call goes out from the number that has been answered most often lately. Money fields are in minor units, so `1000000` is KES 10,000.

### Step 2: Add contacts

[Add contacts](/developers/api/appendDialerContacts) as JSON or CSV. Put the agent's context in `fields`, such as the customer's name and what they owe, and keep your own id in `external_ref` so you can match the results later.

```bash
curl -s -X POST "https://api.sautikit.com/v1/dialer/campaigns/$ID/contacts" \
  -H "Authorization: Bearer $SAUTIKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: november-renewals-batch-1" \
  -d '{
    "contacts": [
      { "phone": "+254700000002", "external_ref": "crm-1042", "fields": { "name": "Amina", "plan": "Gold" } },
      { "phone": "+254700000003", "external_ref": "crm-1043", "fields": { "name": "Joseph", "plan": "Silver" } }
    ]
  }'
```

Numbers on your [do-not-call list](/developers/api/listDialerSuppressions) are never dialled.

### Step 3: Join your agents

Each agent takes calls through a session on their extension. [Join the campaign](/developers/api/joinDialerCampaign) with the extension, then mark the session [ready](/developers/api/readyDialerSession). A session starts paused, so nothing is dialled for an agent until they are ready.

**JavaScript**

```javascript
const session = await fetch(`${BASE}/v1/dialer/campaigns/${campaign.id}/sessions`, {
  method: "POST",
  headers,
  body: JSON.stringify({ extension_id: "2001" }),
}).then((r) => r.json());

await fetch(`${BASE}/v1/dialer/campaigns/${campaign.id}/sessions/${session.id}/ready`, {
  method: "POST",
  headers,
});

// Keep the session alive while the agent's softphone is open.
setInterval(() => {
  fetch(`${BASE}/v1/dialer/campaigns/${campaign.id}/sessions/${session.id}/heartbeat`, {
    method: "POST",
    headers,
  });
}, 30_000);
```

**Python**

```python
session = requests.post(
    f"{BASE}/v1/dialer/campaigns/{campaign['id']}/sessions",
    headers=headers,
    json={"extension_id": "2001"},
).json()

requests.post(
    f"{BASE}/v1/dialer/campaigns/{campaign['id']}/sessions/{session['id']}/ready",
    headers=headers,
)

# Call this every 30 seconds while the agent's softphone is open.
def heartbeat():
    requests.post(
        f"{BASE}/v1/dialer/campaigns/{campaign['id']}/sessions/{session['id']}/heartbeat",
        headers=headers,
    )
```

Send a [heartbeat](/developers/api/heartbeatDialerSession) every 30 seconds. A ready session that has not been seen for 90 seconds gets no new calls, and after 5 minutes it leaves the campaign on its own, so a closed laptop never gets a customer put through to it. To take a break, [pause the session](/developers/api/pauseDialerSession). During a call the pause takes effect when the call ends.

### Step 4: Check the price, then start

[Get the estimate](/developers/api/getDialerCampaignEstimate), then [start the campaign](/developers/api/startDialerCampaign). Starting spends money. You can [pause](/developers/api/pauseDialerCampaign) at any time, and calls already in progress finish normally.

```bash
curl -s "https://api.sautikit.com/v1/dialer/campaigns/$ID/estimate" \
  -H "Authorization: Bearer $SAUTIKIT_API_KEY"

curl -s -X POST "https://api.sautikit.com/v1/dialer/campaigns/$ID/start" \
  -H "Authorization: Bearer $SAUTIKIT_API_KEY"
```

From here SautiKit dials one contact for every ready agent, inside your calling hours. When a person answers, they are connected to that agent's extension. SautiKit can start dialing for an agent who is wrapping up their last call, so the next customer is on the line when they finish.

### Step 5: Watch the campaign and collect the results

The [live board](/developers/api/getDialerLive) shows each agent's state (`ready`, `reserved`, `talking`, `wrap`, `paused`) and the calls in flight. A supervisor screen can follow the same thing as a stream from [campaign events](/developers/api/streamDialerEvents).

For your own records, subscribe a [webhook](/developers/api/createWebhook) to `dialer.call.completed` and `dialer.campaign.completed`. Each call event carries the campaign, contact, attempt and your `external_ref`. When the list is done, read the [campaign report](/developers/api/getDialerCampaignReport) or [export every attempt as CSV](/developers/api/exportDialerAttemptsCsv).

---

## FAQ

### What is a progressive dialer?

A progressive dialer is an outbound calling system that places one call for each agent who is free and connects the person who answers to that agent. Agents skip ringing, unanswered calls and voicemail, and the customer who answers is put straight through to a person.

### What is the difference between progressive and predictive dialing?

Progressive dialing places exactly one call per free agent, so no answered caller is left waiting. Predictive dialing places a few more calls than there are free agents, because some contacts will not pick up. That keeps a large team busier, at the cost of an occasional caller waiting a moment for an agent.

### Do I need a callback URL for a progressive campaign?

No. A progressive campaign connects answered calls to an agent's extension, so it does not post to your server. You still receive every result through webhooks.

### Can I switch a progressive campaign to predictive?

Yes. Pause the campaign and [update it](/developers/api/updateDialerCampaign) with `"mode": "predictive"`. Contacts, attempts and agent sessions are kept. Your plan must include predictive dialing.

### What happens when no agent is free?

Nothing is dialled. A progressive campaign only places a call when an agent is ready to take it, so it slows down when agents are busy or on a break, and speeds up again when they come back.

---

## Next steps

- [Set up a predictive dialer campaign](/developers/guides/run-a-predictive-dialer-campaign): dial ahead of your agents.
- [Run a dialer campaign from the API](/developers/guides/run-a-dialer-campaign): automated calls where your server decides what each contact hears.
- [Browser calling with WebRTC](/developers/guides/browser-calling-with-webrtc): give agents a softphone in your own app.
