Calls API
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.
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"
}'
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"])
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
$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'];
{
"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 | |
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) |
{
"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}
curl https://voice-agent.edesy.in/api/v1/calls/$CONVERSATION_ID \
-H "Authorization: Bearer $EDESY_API_KEY"
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"])
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
$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'];
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,dispositionandextractionare produced after the call ends. All three are always present in the response and arenulluntil post-call analysis completes — treatnullas "not ready yet", not as "no data". Poll for a few seconds, or usecallbackUrl, rather than reading them straight afterPOST /callsreturns.
404 NOT_FOUND if the conversation does not exist in your workspace.
Get a transcript
GET /api/v1/calls/{conversationId}/transcript
{
"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. 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.
{
"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.
dispositionhere is lowercased (qualified,answered,no_answer), whereasGET /api/v1/calls/{id}returns it uppercased (QUALIFIED). Same concept, different casing — normalise before comparing.variablesechoes back what you sent onPOST /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 asnull. 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:
- 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. - 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 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 — configure the agent that handles the call
- Tools — let the agent call your APIs mid-conversation
- Webhook Subscriptions — signed, retried event delivery