# Campaigns API

> Read campaign progress, count how many numbers were called, and pull the outcome of every contact in a campaign into your CRM.

Source: https://edesy.in/docs/voice-agent/api/campaigns

Track your campaigns from your own systems: how far a campaign has got, how many numbers
were actually called, and what happened on each one.

| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/campaigns` | List campaigns with progress counters |
| `GET` | `/api/v1/campaigns/{id}` | One campaign, with exact per-status contact counts |
| `GET` | `/api/v1/campaigns/{id}/calls` | Every contact in the campaign, with its call outcome |

All three need a key with the **`calls:read`** scope. See [API Overview](https://edesy.in/docs/voice-agent/api)
for authentication, rate limits and the response envelope.

> **Read-only.** Creating, starting, pausing and stopping campaigns stays in the dashboard.
> Starting a campaign dials real people and spends credits, so it goes through the
> dashboard's confirmation step rather than a single API call.

## List campaigns

`GET /api/v1/campaigns`

| Query | Type | Description |
|-------|------|-------------|
| `status` | string | `DRAFT`, `SCHEDULED`, `RUNNING`, `PAUSED`, `COMPLETED`, `CANCELLED` |
| `agentId` | integer | Only campaigns run by this agent |
| `limit` / `offset` | integer | Pagination (default 50, max 100) |

Results are ordered newest first.

```bash
curl "https://voice-agent.edesy.in/api/v1/campaigns?status=RUNNING" \
  -H "Authorization: Bearer $EDESY_API_KEY"
```

<!-- response 200 -->
```json
{
  "success": true,
  "data": {
    "campaigns": [
      {
        "id": 1208,
        "name": "October renewals",
        "description": null,
        "status": "RUNNING",
        "agentId": 1042,
        "contactListId": 311,
        "provider": "edesy",
        "scheduledAt": null,
        "startedAt": "2026-09-29T04:30:00.000Z",
        "completedAt": null,
        "totalContacts": 1500,
        "callsCompleted": 1200,
        "callsSuccessful": 950,
        "callsFailed": 250,
        "callsPending": 300,
        "maxParallelCalls": 10,
        "callRatePerMinute": 30,
        "retryEnabled": true,
        "maxRetryAttempts": 2,
        "createdAt": "2026-09-28T11:02:14.000Z",
        "updatedAt": "2026-09-29T06:10:41.000Z"
      }
    ],
    "total": 1,
    "limit": 50,
    "offset": 0
  }
}
```

### Progress counters

| Field | Meaning |
|-------|---------|
| `totalContacts` | Contacts in the campaign |
| `callsCompleted` | Contacts processed so far. **Includes failed calls**: it is `callsSuccessful + callsFailed`, plus any blocked numbers that were skipped |
| `callsSuccessful` | Calls that connected |
| `callsFailed` | Calls that did not connect (busy, no answer, invalid number, …) |
| `callsPending` | Contacts not yet processed |

These counters update live while the campaign runs. When you need an exact figure at a
point in time, use `callStats` from [Get a campaign](#get-a-campaign).

## Get a campaign

`GET /api/v1/campaigns/{id}`

Returns every field from the list endpoint plus **`callStats`**, which is counted directly
from the campaign's contacts when you make the request. Use it to answer *"how many
numbers were called?"*

<!-- tabs -->

```bash
curl "https://voice-agent.edesy.in/api/v1/campaigns/1208" \
  -H "Authorization: Bearer $EDESY_API_KEY"
```

```python
import os
import requests

response = requests.get(
    "https://voice-agent.edesy.in/api/v1/campaigns/1208",
    headers={"Authorization": f"Bearer {os.environ['EDESY_API_KEY']}"},
    timeout=30,
)
response.raise_for_status()

stats = response.json()["data"]["callStats"]
print(f"{stats['dialledContacts']} of {stats['totalContacts']} numbers called")
```

```javascript
const response = await fetch("https://voice-agent.edesy.in/api/v1/campaigns/1208", {
  headers: { Authorization: `Bearer ${process.env.EDESY_API_KEY}` },
});

if (!response.ok) {
  throw new Error(`Edesy API ${response.status}: ${await response.text()}`);
}

const { data: campaign } = await response.json();
const { dialledContacts, totalContacts } = campaign.callStats;
console.log(`${dialledContacts} of ${totalContacts} numbers called`);
```

```php
<?php
$ch = curl_init('https://voice-agent.edesy.in/api/v1/campaigns/1208');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . getenv('EDESY_API_KEY')],
]);

$response = curl_exec($ch);
$status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status !== 200) {
    throw new RuntimeException("Edesy API {$status}: {$response}");
}

$stats = json_decode($response, true)['data']['callStats'];
echo "{$stats['dialledContacts']} of {$stats['totalContacts']} numbers called";
```

<!-- /tabs -->

<!-- response 200 -->
```json
{
  "success": true,
  "data": {
    "id": 1208,
    "name": "October renewals",
    "status": "RUNNING",
    "totalContacts": 1500,
    "callsCompleted": 1200,
    "callsSuccessful": 950,
    "callsFailed": 250,
    "callsPending": 300,
    "startedAt": "2026-09-29T04:30:00.000Z",
    "completedAt": null,
    "callStats": {
      "totalContacts": 1500,
      "dialledContacts": 1260,
      "byStatus": {
        "PENDING": 240,
        "CALLING": 4,
        "COMPLETED": 950,
        "FAILED": 236,
        "RETRY": 60,
        "CANCELLED": 10
      }
    }
  }
}
```

The response also carries every other field shown under [List campaigns](#list-campaigns).

### `callStats`

| Field | Meaning |
|-------|---------|
| `totalContacts` | Contacts in the campaign |
| `dialledContacts` | Contacts that were actually dialled at least once |
| `byStatus` | Contacts in each [status](#contact-statuses). Every status is always present, `0` when none |

### Errors

| Status | Code | When |
|--------|------|------|
| 400 | `INVALID_CAMPAIGN_ID` | `{id}` is not an integer |
| 404 | `NOT_FOUND` | No campaign with this id in your workspace |

## List the calls in a campaign

`GET /api/v1/campaigns/{id}/calls`

One row per contact in the campaign, including contacts that have not been dialled yet.
This is the endpoint to sync campaign outcomes into a CRM.

| Query | Type | Description |
|-------|------|-------------|
| `status` | string | Only contacts in this [status](#contact-statuses) |
| `limit` / `offset` | integer | Pagination (default 50, max 100) |

Rows are ordered by `id`, oldest contact first, so paging with `offset` stays stable
while the campaign is still dialling.

<!-- tabs -->

```bash
curl "https://voice-agent.edesy.in/api/v1/campaigns/1208/calls?status=COMPLETED&limit=100" \
  -H "Authorization: Bearer $EDESY_API_KEY"
```

```python
import os
import requests

BASE = "https://voice-agent.edesy.in/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['EDESY_API_KEY']}"


def campaign_calls(campaign_id, status=None):
    """Yield every contact in a campaign, one page at a time."""
    offset = 0
    while True:
        params = {"limit": 100, "offset": offset}
        if status:
            params["status"] = status
        r = session.get(f"{BASE}/campaigns/{campaign_id}/calls", params=params, timeout=30)
        r.raise_for_status()
        page = r.json()["data"]
        yield from page["calls"]
        offset += len(page["calls"])
        if offset >= page["total"] or not page["calls"]:
            break


for call in campaign_calls(1208):
    print(call["phoneNumber"], call["status"], call["disposition"], call["durationSec"])
```

```javascript
const BASE = "https://voice-agent.edesy.in/api/v1";
const headers = { Authorization: `Bearer ${process.env.EDESY_API_KEY}` };

async function* campaignCalls(campaignId, status) {
  let offset = 0;
  while (true) {
    const qs = new URLSearchParams({ limit: "100", offset: String(offset) });
    if (status) qs.set("status", status);

    const res = await fetch(`${BASE}/campaigns/${campaignId}/calls?${qs}`, { headers });
    if (!res.ok) throw new Error(`Edesy API ${res.status}: ${await res.text()}`);

    const { data } = await res.json();
    yield* data.calls;
    offset += data.calls.length;
    if (offset >= data.total || data.calls.length === 0) break;
  }
}

for await (const call of campaignCalls(1208)) {
  console.log(call.phoneNumber, call.status, call.disposition, call.durationSec);
}
```

```php
<?php
function campaign_calls(int $campaignId, ?string $status = null): Generator {
    $offset = 0;
    while (true) {
        $query = ['limit' => 100, 'offset' => $offset];
        if ($status !== null) {
            $query['status'] = $status;
        }

        $ch = curl_init("https://voice-agent.edesy.in/api/v1/campaigns/{$campaignId}/calls?" . http_build_query($query));
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . getenv('EDESY_API_KEY')],
        ]);
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode !== 200) {
            throw new RuntimeException("Edesy API {$httpCode}: {$response}");
        }

        $page = json_decode($response, true)['data'];
        yield from $page['calls'];
        $offset += count($page['calls']);
        if ($offset >= $page['total'] || count($page['calls']) === 0) {
            break;
        }
    }
}

foreach (campaign_calls(1208) as $call) {
    echo "{$call['phoneNumber']} {$call['status']} {$call['disposition']}", PHP_EOL;
}
```

<!-- /tabs -->

<!-- response 200 -->
```json
{
  "success": true,
  "data": {
    "calls": [
      {
        "id": 88121,
        "phoneNumber": "+919876543210",
        "contactName": "Asha",
        "status": "COMPLETED",
        "attemptNumber": 1,
        "lastAttemptAt": "2026-09-29T05:31:00.000Z",
        "nextRetryAt": null,
        "callStartedAt": "2026-09-29T05:31:02.000Z",
        "callCompletedAt": "2026-09-29T05:32:36.000Z",
        "durationSec": 94,
        "disposition": "QUALIFIED",
        "customDisposition": null,
        "endReason": "end_call",
        "errorMessage": null,
        "fromNumber": "+918035000000",
        "conversationId": "9adb70562f38fca1",
        "callSummary": "Customer confirmed renewal for next month.",
        "extractedData": { "renewal_month": "November" }
      }
    ],
    "total": 950,
    "limit": 100,
    "offset": 0
  }
}
```

### Call fields

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | This contact's id within the campaign |
| `phoneNumber` | string | Number dialled (E.164) |
| `contactName` | string \| null | Name from your contact list |
| `status` | string | [Contact status](#contact-statuses) |
| `attemptNumber` | integer | 1 for the first dial, increasing with each retry |
| `lastAttemptAt` | datetime \| null | When the latest attempt was made |
| `nextRetryAt` | datetime \| null | When the next retry is scheduled, if any |
| `callStartedAt` / `callCompletedAt` | datetime \| null | Start and end of the call |
| `durationSec` | integer \| null | Call length in seconds |
| `disposition` | string \| null | The outcome. For connected calls, what the agent recorded (e.g. `QUALIFIED`, `CALLBACK_REQUESTED`, `NOT_INTERESTED`). For calls that never connected, the carrier's outcome (e.g. `BUSY`, `NO_ANSWER`, `WRONG_NUMBER`) |
| `customDisposition` | string \| null | Your agent's custom outcome label, if one was used |
| `endReason` | string \| null | Why the call ended |
| `errorMessage` | string \| null | Failure reason for calls that did not connect |
| `fromNumber` | string \| null | Caller ID used |
| `conversationId` | string \| null | Use with [`GET /api/v1/calls/{conversationId}`](https://edesy.in/docs/voice-agent/api/calls) for the transcript and recording. `null` if the call never connected |
| `callSummary` | string \| null | AI summary, when post-call analysis is enabled |
| `extractedData` | object \| null | Fields extracted after the call, when post-call extraction is enabled |

> **Filtered status vs returned status.** The `status` filter matches the status the
> dialler recorded. Occasionally a call is recorded as `FAILED` even though a
> conversation took place; that row is returned as `COMPLETED`, with its real duration
> and outcome. So `?status=FAILED` can include a few rows that show `COMPLETED`.

### Errors

| Status | Code | When |
|--------|------|------|
| 400 | `INVALID_CAMPAIGN_ID` | `{id}` is not an integer |
| 400 | `INVALID_STATUS` | `status` is not one of the contact statuses |
| 404 | `NOT_FOUND` | No campaign with this id in your workspace |

## Contact statuses

| Status | Meaning |
|--------|---------|
| `PENDING` | Not dialled yet |
| `CALLING` | Call in progress |
| `COMPLETED` | Call connected and finished |
| `FAILED` | Did not connect, and no retries are left |
| `RETRY` | Did not connect; another attempt is scheduled (see `nextRetryAt`) |
| `CANCELLED` | Will not be dialled, e.g. the number is on your block list or the campaign was cancelled |

## Generating a client

All three endpoints are in the OpenAPI spec at
`GET https://voice-agent.edesy.in/api/v1/openapi.json` (operations `listCampaigns`,
`getCampaign` and `listCampaignCalls`), so you can generate a typed client in any
language.

## Next steps

- [Calls](https://edesy.in/docs/voice-agent/api/calls): transcript, recording and summary for a single call
- [Webhook Subscriptions](https://edesy.in/docs/voice-agent/api/webhook-subscriptions): get pushed each outcome as it happens instead of polling
- [Campaigns guide](https://edesy.in/docs/voice-agent/campaigns): create and run campaigns in the dashboard
