# Calls API

> Place outbound and test calls, read call history, fetch a call's transcript, summary and recording, and receive a callback when a call ends.

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

Place calls with an agent, then read back what happened — status, duration, recording,
transcript, summary and extracted fields.

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/v1/calls` | Place an outbound call |
| `GET` | `/api/v1/calls` | Call history |
| `GET` | `/api/v1/calls/{conversationId}` | Full call detail |
| `GET` | `/api/v1/calls/{conversationId}/transcript` | Transcript only |

## Place a call

`POST /api/v1/calls` → **200 OK**

This is the endpoint behind the dashboard's **Test Call** — the same orchestration,
credit checks and backend selection.

<!-- tabs -->

```bash
curl -X POST https://voice-agent.edesy.in/api/v1/calls \
  -H "Authorization: Bearer $EDESY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": 1042,
    "phoneNumber": "+919876543210",
    "variables": {
      "customer_name": "Rahul",
      "order_id": "ACM-88213"
    },
    "metadata": { "crm_lead_id": "L-5521" },
    "callbackUrl": "https://example.com/hooks/edesy/call-ended"
  }'
```

```python
import os
import requests

response = requests.post(
    "https://voice-agent.edesy.in/api/v1/calls",
    headers={"Authorization": f"Bearer {os.environ['EDESY_API_KEY']}"},
    json={
        "agentId": 1042,
        "phoneNumber": "+919876543210",
        "variables": {"customer_name": "Rahul", "order_id": "ACM-88213"},
        "metadata": {"crm_lead_id": "L-5521"},
        "callbackUrl": "https://example.com/hooks/edesy/call-ended",
    },
    timeout=30,
)
response.raise_for_status()

call = response.json()["data"]
print(call["conversationId"])
```

```javascript
const response = await fetch("https://voice-agent.edesy.in/api/v1/calls", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.EDESY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    agentId: 1042,
    phoneNumber: "+919876543210",
    variables: { customer_name: "Rahul", order_id: "ACM-88213" },
    metadata: { crm_lead_id: "L-5521" },
    callbackUrl: "https://example.com/hooks/edesy/call-ended",
  }),
});

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

const { data: call } = await response.json();
console.log(call.conversationId);
```

```php
<?php
$ch = curl_init('https://voice-agent.edesy.in/api/v1/calls');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('EDESY_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'agentId'     => 1042,
        'phoneNumber' => '+919876543210',
        'variables'   => ['customer_name' => 'Rahul', 'order_id' => 'ACM-88213'],
        'metadata'    => ['crm_lead_id' => 'L-5521'],
        'callbackUrl' => 'https://example.com/hooks/edesy/call-ended',
    ]),
]);

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

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

$call = json_decode($response, true)['data'];
echo $call['conversationId'];
```

<!-- /tabs -->

<!-- response 200 -->
```json
{
  "success": true,
  "data": {
    "conversationId": "c3f1b6e2-8f5a-4a1e-9d21-7f0a2c4b9e11",
    "callSid": "CA9f0e1d2c3b4a5968778695a4b3c2d1e0",
    "status": "initiated"
  }
}
```

The response returns as soon as the call is **dispatched to the carrier**. Poll
`GET /api/v1/calls/{conversationId}` or use `callbackUrl` for the outcome.

### Request body

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `agentId` | integer | Yes | The agent that will handle the call |
| `phoneNumber` | string | Yes | Destination. E.164 preferred |
| `variables` | object | | Values for the agent's [declared variables](https://edesy.in/docs/voice-agent/api/agents#variables) |
| `metadata` | object | | Arbitrary data echoed back on the call record and callback |
| `callbackUrl` | string (HTTPS) | | Notified when the call ends |
| `fromNumber` | string | | Caller ID to present. Must be a number configured in your workspace |
| `provider` | string | | Carrier override. Defaults to the agent's `callProvider`, then the workspace default |
| `credentialId` | string | | Use a specific stored carrier credential |
| `runtime` | `v1` \| `v2` \| `v3` | | Voice stack override. Leave unset unless advised |

**Phone number normalisation.** Numbers already carrying a country code (`+…` or `00…`)
are preserved exactly. A bare 10-digit Indian mobile (`9876543210`), an 11-digit
trunk-prefixed one (`09876543210`), or a 12-digit `91…` form are normalised to `+91…`.
Anything else is passed through digits-only for the carrier to validate. **Send E.164
for international destinations** — an unprefixed number can be misrouted to the wrong
country.

### Errors

| Status | Code | When |
|--------|------|------|
| 400 | `MISSING_AGENT_ID` | `agentId` absent |
| 400 | `MISSING_PHONE_NUMBER` | `phoneNumber` absent |
| 400 | `CALL_FAILED` | Agent not ready (no LLM provider or no prompt), or `callbackUrl` is not a valid HTTPS URL |
| 402 | `CALL_FAILED` | Insufficient credits — the response includes the balance detail |
| 429 | `CALL_FAILED` | Workspace concurrent-call limit reached |
| 503 | `CALL_FAILED` | Voice backend unreachable |
| 504 | `CALL_FAILED` | Backend timed out (30s) |

## Call history

`GET /api/v1/calls`

| Query | Type | Description |
|-------|------|-------------|
| `agentId` | integer | Filter by agent |
| `status` | string | `completed`, `failed`, `dialing`, `not_connected` |
| `phoneNumber` | string | Exact match on the recipient |
| `startDate` / `endDate` | ISO date | Range on call start time |
| `limit` / `offset` | integer | Pagination (max 100) |

<!-- response 200 -->
```json
{
  "success": true,
  "data": {
    "calls": [
      {
        "conversationId": "c3f1b6e2-8f5a-4a1e-9d21-7f0a2c4b9e11",
        "callSid": "CA9f0e1d2c3b4a5968778695a4b3c2d1e0",
        "agentId": 1042,
        "agentName": "Order Status Bot",
        "phoneNumber": "+919876543210",
        "status": "completed",
        "duration": 74,
        "recordingUrl": "https://…",
        "source": "api",
        "startTime": "2026-08-10T09:31:02.000Z",
        "endTime": "2026-08-10T09:32:16.000Z",
        "metadata": { "crm_lead_id": "L-5521" }
      }
    ],
    "total": 128,
    "limit": 50,
    "offset": 0
  }
}
```

Results are newest-first by start time.

## Get a call

`GET /api/v1/calls/{conversationId}`

<!-- tabs -->

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

```python
import os
import requests

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

call = response.json()["data"]
print(call["status"], call["duration"])
print(call["fullText"])
```

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

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

const { data: call } = await response.json();
console.log(call.status, call.duration);
console.log(call.fullText);
```

```php
<?php
$ch = curl_init("https://voice-agent.edesy.in/api/v1/calls/{$conversationId}");
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}");
}

$call = json_decode($response, true)['data'];
echo $call['status'], ' ', $call['duration'], PHP_EOL;
echo $call['fullText'];
```

<!-- /tabs -->

Returns everything from the list shape, plus:

| Field | Description |
|-------|-------------|
| `transcript` | Ordered turns: `{ role: "user" \| "agent", text, timestamp }` |
| `fullText` | The transcript flattened to `Agent: … / User: …` lines |
| `summary` | AI-generated post-call summary |
| `disposition` | Call outcome classification — one of the values below |
| `extraction` | Structured fields extracted from the conversation |
| `recordingUrl` | Playable recording link, or `null` when there is no recording |

**`disposition` values.** A fixed set:

| Value | Meaning |
|-------|---------|
| `QUALIFIED` | The caller met your qualification criteria |
| `NOT_QUALIFIED` | They did not |
| `CALLBACK_REQUESTED` | They asked to be called back |
| `NO_ANSWER` | Nobody picked up |
| `VOICEMAIL` | Reached an answering machine |
| `TRANSFERRED` | Handed off to a human |
| `CUSTOM` | An outcome you defined yourself |

**`extraction`.** An object whose keys are the extraction fields configured on the
agent, so the shape is **yours, not ours** — an agent with `order_id` and `issue_type`
fields yields `{"order_id": "ACM-88213", "issue_type": "delivery_delay"}`. It is `null`
until post-call extraction runs, and stays `null` for calls with no transcript.
Configure the fields in the dashboard under the agent's data fields.

> **`summary`, `disposition` and `extraction` are produced *after* the call ends.** All
> three are always present in the response and are `null` until post-call analysis
> completes — treat `null` as "not ready yet", not as "no data". Poll for a few seconds,
> or use `callbackUrl`, rather than reading them straight after `POST /calls` returns.

`404 NOT_FOUND` if the conversation does not exist in your workspace.

## Get a transcript

`GET /api/v1/calls/{conversationId}/transcript`

<!-- response 200 -->
```json
{
  "success": true,
  "data": {
    "conversationId": "c3f1b6e2-8f5a-4a1e-9d21-7f0a2c4b9e11",
    "transcript": [
      { "role": "agent", "text": "Namaste! Acme support…", "timestamp": "2026-08-10T09:31:04.220Z" },
      { "role": "user", "text": "Order ACM-88213", "timestamp": "2026-08-10T09:31:09.870Z" }
    ],
    "fullText": "Agent: Namaste! Acme support…\nUser: Order ACM-88213"
  }
}
```

Lighter than the full call detail — use it when you only need the conversation text.

## Call-ended callback

Polling works, but a callback is better: you get told once, when the call is actually
over.

Set `callbackUrl` on `POST /api/v1/calls` and we POST to it after the call ends. The
URL must be **HTTPS** in production; an invalid URL is rejected at call-creation time
rather than silently dropped.

> This is **not** the same mechanism as
> [Webhook Subscriptions](https://edesy.in/docs/voice-agent/api/webhook-subscriptions). The per-call
> callback described here is unsigned, single-delivery, and emits only
> `call.completed` / `call.failed` / `call.voicemail`.

### Payload

`Content-Type: application/json`, 15-second timeout.

<!-- code-title Callback payload -->
```json
{
  "event": "call.completed",
  "timestamp": "2026-08-10T09:32:16Z",
  "data": {
    "conversation_id": "c3f1b6e2-8f5a-4a1e-9d21-7f0a2c4b9e11",
    "call_sid": "CA9f0e1d2c3b4a5968778695a4b3c2d1e0",
    "phone_number": "+919876543210",
    "agent_id": 1042,
    "duration": 74,
    "disposition": "qualified",
    "transcript_summary": "Caller asked about order ACM-88213; confirmed delivery for Thursday.",
    "turn_count": 12,
    "recording_url": "https://…",
    "variables": { "customer_name": "Rahul", "order_id": "ACM-88213" }
  }
}
```

| `event` | Sent when |
|---------|-----------|
| `call.completed` | The call connected and finished normally |
| `call.failed` | The call failed or went unanswered |
| `call.voicemail` | An answering machine was reached |

Notes that will save you a debugging session:

- **The payload is snake_case**, unlike the REST responses, which are camelCase.
- **`disposition` here is lowercased** (`qualified`, `answered`, `no_answer`), whereas
  `GET /api/v1/calls/{id}` returns it uppercased (`QUALIFIED`). Same concept, different
  casing — normalise before comparing.
- **`variables` echoes back what you sent** on `POST /api/v1/calls`, which is the
  simplest way to correlate the callback with your own record.
- Optional fields (`recording_url`, `transcript_summary`, `turn_count`,
  `conversation_id`, `variables`) are **omitted entirely** when empty, not sent as
  `null`. Read them defensively.

### Delivery and verification

This callback is delivered **once, unsigned, and unretried** — treat it as a fast
notification rather than a system of record. Two consequences:

1. **Verify out of band.** On receipt, call `GET /api/v1/calls/{conversation_id}` with
   your API key and use that response as the truth. Don't act on the callback body
   alone, and don't expose the endpoint as if the payload were authenticated.
2. **Reconcile.** If your endpoint is down or slow, that call's notification is lost.
   Periodically sweep `GET /api/v1/calls?startDate=…` for calls you never processed.

If you need signed, retried delivery, use
[Webhook Subscriptions](https://edesy.in/docs/voice-agent/api/webhook-subscriptions) instead.

## A note on scopes

`POST /api/v1/calls`, `GET /api/v1/calls`, `GET /api/v1/calls/{id}` and
`GET /api/v1/calls/{id}/transcript` currently require a **valid API key** and enforce
workspace scoping, but do not yet enforce per-scope checks. Grant `calls:read` /
`calls:write` on your keys anyway — scope enforcement will be applied to these
endpoints, and keys already carrying the right scopes will keep working unchanged.

## Next steps

- [Agents](https://edesy.in/docs/voice-agent/api/agents) — configure the agent that handles the call
- [Tools](https://edesy.in/docs/voice-agent/api/tools) — let the agent call your APIs mid-conversation
- [Webhook Subscriptions](https://edesy.in/docs/voice-agent/api/webhook-subscriptions) — signed, retried event delivery
