ExoCall Developer Docs
Public API & Webhooks
Integrate your CRM, website forms, or automation tools (Zapier, Pabbly Connect, Make) with ExoCall: trigger AI calls from your systems, and receive call outcomes — lead labels, summaries, transcripts — back into them the moment each call finishes.
1. Authentication
Generate an API key in Settings → API & Webhooks. The key is shown once — store it securely. Send it on every request:
Authorization: Bearer exo_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Base URL: https://app.exocall.io/api/v1. All requests and responses are JSON. Keys are scoped to your workspace — you can only ever see your own data.
2. Endpoints
Trigger an outbound AI call right now (speed-to-lead).
curl -X POST https://app.exocall.io/api/v1/calls \
-H "Authorization: Bearer exo_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lead-8412" \
-d '{
"phone": "+919876543210",
"contact_name": "Ravi Kumar",
"purpose": "New enquiry from website — interested in the Growth plan. Qualify and book a demo."
}'
# 201 Created
{ "call_sid": "abc123...", "phone": "09876543210", "status": "initiated" }purpose is given to the AI agent as the call objective. The optional Idempotency-Key header makes retries safe — the same key within 24 hours returns the original response instead of dialing again.
One call's full outcome: status, duration, AI analysis and transcript.
curl https://app.exocall.io/api/v1/calls/1234 \
-H "Authorization: Bearer exo_live_..."
{
"id": 1234,
"call_type": "outbound",
"phone": "09876543210",
"duration_seconds": 142,
"ended_by": "ai",
"lead_label": "hot", // hot | warm | cold | not_interested | callback_requested
"lead_score": 4, // 1–5
"callback_datetime": null,
"summary": "Customer asked about pricing...",
"intent": "pricing",
"sentiment": "positive",
"language_used": "Hinglish",
"key_insight": "Ready to buy if EMI option is available",
"analysis_extra": { // extended analysis (null on older calls)
"call_outcome": "completed", // completed | caller_hung_up_early | voicemail | wrong_person | dropped
"goal_achieved": true, // outbound calls with a purpose only
"next_action": "Send EMI plan on WhatsApp, call back Thursday",
"follow_up_message": "Ready-to-send WhatsApp text in the caller's language",
"customer_details": { "name": "Ravi", "city": "Surat" },
"competitors_mentioned": ["..."],
"questions_unanswered": ["..."],
"agent_performance": 4, // 1–5 AI agent QA rating
"agent_mistakes": null,
"agent_talk_percent": 55
},
"recording_url": "https://...",
"transcript": [ { "role": "agent", "text": "...", "spoken_at": "..." } ]
}Paginated call history. Filters: limit (max 100), offset, call_type (inbound/outbound), lead_label, search (phone), date_from/date_to (YYYY-MM-DD), campaign_id.
curl "https://app.exocall.io/api/v1/call-logs?lead_label=hot&limit=50" \
-H "Authorization: Bearer exo_live_..."
{ "total": 8, "stats": { ... }, "calls": [ { "id": 1234, "lead_label": "hot", ... } ] }Push a lead in from your CRM or form. Without campaign_id the lead is called immediately; with it, the lead is added to that draft campaign instead.
curl -X POST https://app.exocall.io/api/v1/leads \
-H "Authorization: Bearer exo_live_..." \
-H "Content-Type: application/json" \
-d '{ "phone": "+919876543210", "name": "Ravi", "purpose": "Follow up on demo request" }'
# 201 → { "call_sid": "...", "phone": "09876543210", "status": "call_initiated" }
# Or add to a campaign instead of calling now:
# { "phone": "+919876543210", "name": "Ravi", "campaign_id": 12 }
# 201 → { "added_to_campaign": 12, "phone": "+919876543210", "total_leads": 341 }Bulk-add up to 1,000 contacts to a draft campaign in one request.
curl -X POST https://app.exocall.io/api/v1/campaigns/12/contacts \
-H "Authorization: Bearer exo_live_..." \
-H "Content-Type: application/json" \
-d '{ "contacts": [
{ "phone": "+919876543210", "name": "Ravi", "notes": "From October webinar" },
{ "phone": "+919812345678", "name": "Meena" }
] }'
# 201 → { "campaign_id": 12, "added": 2, "total_leads": 343 }3. Webhooks — call events pushed to you
Add a webhook URL in Settings → API & Webhooks and ExoCall will POST call events to it as they happen. This is how call results flow back into your CRM without polling.
call.completed
Call ended — phone, duration, who ended it.
call.analyzed
AI analysis saved (usually within ~1 minute of call end) — lead label, score, summary, intent, sentiment, callback time and full transcript. This is the event most CRM workflows should use.
ping
Sent by the "Send test event" button in Settings.
Example call.analyzed delivery:
POST <your url>
Content-Type: application/json
X-Exocall-Event: call.analyzed
X-Exocall-Signature: sha256=2f0a1b...
{
"event": "call.analyzed",
"created_at": "2026-07-14T10:32:00.000Z",
"data": {
"call_id": 1234,
"call_type": "outbound",
"phone": "09876543210",
"duration_seconds": 142,
"lead_label": "hot",
"lead_score": 4,
"callback_datetime": null,
"summary": "Customer asked about pricing and EMI options...",
"intent": "pricing",
"sentiment": "positive",
"language_used": "Hinglish",
"key_insight": "Ready to buy if EMI option is available",
"analysis_extra": { "call_outcome": "completed", "next_action": "...", "agent_performance": 4, ... },
"recording_url": "https://...",
"transcript": [ { "role": "agent", "text": "...", "spoken_at": "..." } ]
}
}Respond with any 2xx status within 10 seconds. Failed deliveries are retried after 1m, 5m, 30m and 2h; after 10 consecutive failures the endpoint is disabled until you re-enable it in Settings.
4. Verifying webhook signatures
Every delivery is signed with your endpoint's secret (shown in Settings, prefixed whsec_). Compute HMAC-SHA256 over the raw request body and compare:
// Node.js / Express example
const crypto = require('crypto');
app.post('/exocall-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.EXOCALL_WEBHOOK_SECRET)
.update(req.body) // raw bytes, NOT parsed JSON
.digest('hex');
const received = req.get('X-Exocall-Signature') || '';
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
// ... update your CRM ...
res.status(200).end();
});5. Errors & rate limits
All errors share one shape:
{ "error": { "code": "minutes_exhausted", "message": "Included minutes exhausted (500/500 used...)" } }| Status | Code | Meaning |
|---|---|---|
| 401 | missing_api_key / invalid_api_key | No key, malformed header, or revoked key |
| 400 | invalid_phone / invalid_json / … | Bad request body — the message says exactly what |
| 402 | subscription_suspended / minutes_exhausted | Billing state blocks outbound calling |
| 404 | not_found / campaign_not_found | Resource doesn’t exist on this account |
| 429 | rate_limited / concurrency_limit | Over 60 req/min per key, or all call slots busy |
| 503 | service_unavailable | Temporary — safe to retry with backoff |
Rate limit: 60 requests/minute per API key. A 429 includes a Retry-After header in seconds.