Cora API — v1
Agent-first REST API for Cora, an AI workforce for outbound sales. Manage leads and AI agents, trigger real phone calls and emails, and read conversation history and metrics. Every endpoint maps 1:1 to an MCP tool, so anything documented here is also callable by an AI agent through the Cora MCP server.
- Base URL:
https://YOUR-APP/api/v1— replace with your deployment. Local dev:http://localhost:3000/api/v1. - Auth:
Authorization: Bearer cora_live_... - Content-Type:
application/jsonfor request bodies. - This doc (markdown):
GET /api/v1/docs· Machine-readable index:GET /api/v1
Connect via MCP — recommended for AI agents
If you're an AI agent (Claude, Cursor, …), connect through the Cora MCP server, not the raw REST API. Add Cora as a custom connector and you sign in once with Cora — no API key to manage, and every endpoint below is exposed as an MCP tool you can call directly.
- Connector URL:
https://YOUR-APP/api/mcp - Claude Code:
claude mcp add --transport http cora https://YOUR-APP/api/mcp - Claude Desktop / Cursor: add a custom connector with that URL.
On first use a browser opens to sign in with Cora and approve access; the tools then work across all your projects. The MCP tool names match the operations in this reference (e.g. list_leads, get_lead, create_lead, trigger_call, send_email, get_call_audio, get_stats). The REST API below is the alternative for scripts/backends that hold an API key.
For AI agents — read this first
Cora's job is outbound sales: it stores leads (the people you're selling to), runs agents (AI workers that call and email leads), and records every call and email.
Typical flows:
- "What's happening?" →
GET /v1/statsfor totals,GET /v1/activityfor the recent event feed. - "Find a lead" →
GET /v1/leads?query=jane. ThenGET /v1/leads/{id}for full detail incl. recent calls, emails, and activity. - "Add leads" →
POST /v1/leads(one) orPOST /v1/leads/bulk(up to 500; duplicates by email are skipped). - "Reach out" →
POST /v1/outreach/callorPOST /v1/outreach/email. These place real calls / send real emails. Always confirm intent with the human before calling these. A call needs the lead to have a phone number and the agent to have a phone number assigned. - "Set up an agent" →
GET /v1/agents,POST /v1/agents,PATCH /v1/agents/{id}, thenPOST /v1/agents/{id}/assign-leads.
Rules of thumb: read endpoints are safe and free to call; write endpoints change real data; outreach endpoints have real-world side effects. Errors are descriptive (see Error codes) — read error.message and adjust, don't blindly retry.
Authentication
Pass your API key as a Bearer token on every request:
curl -H "Authorization: Bearer cora_live_..." https://YOUR-APP/api/v1/leads
Keys have a scope: full (read + write) or read_only (GET only). A read_only key calling a write endpoint returns 403 read_only_key. Create and revoke keys in the Cora app (project dashboard → API tab); the raw key is shown once at creation.
| Failure | Status | code |
|---|---|---|
No Authorization header | 401 | missing_api_key |
| Unknown / malformed key | 401 | invalid_api_key |
| Revoked key | 401 | revoked_api_key |
| read_only key on a write | 403 | read_only_key |
Conventions
Response envelope
Every successful response wraps its payload under a top-level data key — both single-object and list endpoints. The per-endpoint "Response" examples below show the inner shape (what's under data).
{ "data": { ... } } // single object
{ "data": [ ... ], "has_more": false, "next_cursor": null } // list
Pagination
List endpoints return their items under data plus paging fields:
{
"data": [ ... ],
"has_more": true,
"next_cursor": "eyJ0cyI6..."
}
Pass limit (1–100, default 50) and cursor (the previous response's next_cursor). When has_more is false, next_cursor is null.
Errors (Stripe-style)
Every error has the same envelope:
{
"error": {
"type": "invalid_request_error",
"code": "lead_no_phone",
"message": "This lead has no phone number.",
"param": "lead_id",
"status": 422
}
}
Idempotency
Send Idempotency-Key: <unique-id> on write requests. A repeated key within 24h returns the cached original response instead of acting twice. It is required on POST /v1/outreach/call (missing → 400 missing_idempotency_key) and optional (but recommended) on creates.
Rate limits
120 requests/minute per key. Every response includes:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset(unix seconds)
Over the limit → 429 rate_limit_exceeded with retry_after (seconds) in the body.
Timestamps, IDs, headers
- Timestamps — ISO 8601 UTC strings (or null).
- IDs — opaque strings.
- Request ID — every response carries
X-Request-Id(include it when reporting issues). - CORS — open (
*), safe because auth is header-based, not cookie-based.
Endpoints
Leads
GET/v1/leadslist / search
List or search leads.
Params
query— matches name/email/phone.status— stage filter:new|in_progress|converted.agent_id— filter by assigned agent.has_meeting—true/false.limit— page size.cursor— pagination cursor.
curl -H "Authorization: Bearer $KEY" "https://YOUR-APP/api/v1/leads?query=acme&has_meeting=true&limit=20"
Response — each lead:
{
"id": "...",
"name": "...",
"first_name": "...",
"last_name": "...",
"company": "...",
"email": "...",
"phone": "...",
"stage": "...",
"meeting_booked": false,
"converted": false,
"outreach_disabled": false,
"assigned_user_uid": "...",
"created_at": "...",
"total_calls": null,
"total_emails": null,
"last_activity_at": null
}
total_* are null in list view; populated on the single-lead GET.
GET/v1/leads/{lead_id}full detail
Returns the lead plus its recent calls, emails, and activity arrays and accurate total_calls / total_emails / last_activity_at.
POST/v1/leadscreate
Create a lead. Provide at least one of name, first_name, last_name, company.
Body
{
"name": "Jane Doe",
"email": "jane@acme.com",
"phone": "+14155550123",
"company": "Acme",
"role": "VP Sales",
"stage": "new",
"agent_id": "optional-agent",
"custom_fields": {}
}
phone must be E.164 (+ then digits). Returns 201 with the created lead. Honors Idempotency-Key.
POST/v1/leads/bulkbulk create (≤ 500)
Create up to 500 leads at once. Duplicates by email are skipped.
Body
{
"leads": [
{ "name": "...", "email": "..." }
],
"agent_id": "optional"
}
Response
{
"data": {
"created": [ { "id": "...", "name": "...", "email": "..." } ],
"count": 0,
"duplicates": 0,
"errors": 0
}
}
PATCH/v1/leads/{lead_id}update
Send only the fields to change.
Body
name— lead's full name.first_name— lead's first name.last_name— lead's last name.email— lead's email.phone— lead's phone.company— lead's company.role— lead's role.stage— lead's stage.meeting_booked— bool.outreach_disabled— bool.
Conversations
GET/v1/leads/{lead_id}/callslist calls for a lead
Returns the call history for a single lead.
Params
limit— max number of results to return.cursor— pagination cursor.
Response — each call has the shape:
{
"id": "...",
"lead_id": "...",
"agent_id": "...",
"direction": "...",
"lead_phone": "...",
"agent_phone": "...",
"outcome": "connected",
"duration_seconds": 0,
"summary": "...",
"transcript_url": "...",
"recording_url": "...",
"started_at": "...",
"finished_at": "..."
}
outcome ∈ connected | voicemail | no_answer.
GET/v1/leads/{lead_id}/emailslist emails for a lead
Returns the email history for a single lead.
Params
limit— max number of results to return.cursor— pagination cursor.
Response — each email has the shape:
{
"id": "...",
"lead_id": "...",
"agent_id": "...",
"subject": "...",
"body": "...",
"summary": "...",
"lead_email": "...",
"status": "...",
"workflow_id": "...",
"created_at": "..."
}
GET/v1/calls/{call_id}/audiofetch a call recording
Streams the raw audio bytes of a call's recording (resolve call_id from GET /v1/leads/{lead_id}/calls). Provider auth is handled server-side. The response Content-Type is the audio MIME (e.g. audio/mpeg).
curl -H "Authorization: Bearer $KEY" \
https://YOUR-APP/api/v1/calls/CALL_ID/audio --output call.mp3
If no recording exists for the call, returns 404 recording_unavailable — this usually means call recording/transcription isn't enabled for the project or agent. (MCP tool: get_call_audio returns the audio inline plus a transcript_url when present.)
GET/v1/activityrecent event feed
Returns a feed of recent events.
Params
lead_id— filter by lead.agent_id— filter by agent.event_type— filter by event type.limit— max number of results to return.cursor— pagination cursor.
Response — each entry has the shape:
{
"id": "...",
"agent_id": "...",
"agent_type": "...",
"event_type": "...",
"title": "...",
"detail": "...",
"status": "...",
"lead_id": "...",
"lead_name": "...",
"metadata": {},
"created_at": "..."
}
Agents
GET/v1/agentslist
List agents.
Params
type— filter by agent type (outbound|inbound).limit— max number of agents to return.cursor— pagination cursor.
Response — each agent has this shape:
{
"id": "...",
"type": "...",
"name": "...",
"mode": "...",
"automation_enabled": false,
"twilio_number": "...",
"email_address": "...",
"language": "...",
"voice_id": "...",
"elevenlabs_agent_id": "...",
"created_at": "..."
}
GET/v1/agents/{agent_id}detail + stats
Returns a single agent plus aggregate stats.
Response — same shape as the list item, with these additional fields:
{
"total_leads_assigned": 0,
"total_calls": 0,
"total_emails": 0
}
POST/v1/agentscreate
Create an agent. Voice provisioning is configured in-app; the API creates the agent record with automation off.
Body
name— agent name.type— required (outbound|inbound).outreach_model— one ofcall|email|both.language— agent language.
{
"name": "Sales Agent",
"type": "outbound",
"outreach_model": "both",
"language": "en"
}
PATCH/v1/agents/{agent_id}update
Update an agent's settings and brain text fields.
Body
name— agent name.automation_enabled— bool.outreach_model— one ofcall|email|both.language— agent language.system_prompt— brain text field.product_explanation— brain text field.value_proposition— brain text field.pain_points— brain text field.benefits— brain text field.features_differentiators— brain text field.elevator_pitch— brain text field.first_message— brain text field.email_knowledge— brain text field.example_email— brain text field.
POST/v1/agents/{agent_id}/assign-leads
Assign leads to an agent.
Body — provide either lead_ids or assign_all_unassigned:
{
"lead_ids": ["id1", "id2"]
}
{
"assign_all_unassigned": true
}
Response
{
"data": {
"assigned": 0,
"total": 0
}
}
Outreach — real side effects
POST/v1/outreach/callplace a real call
Place a real call to a lead. An Idempotency-Key header is required.
Body
lead_id— the lead to call (required).agent_id— the agent placing the call (required).call_reason— reason for the call (optional).first_message_override— overrides the agent's first message (optional).
{
"lead_id": "...",
"agent_id": "...",
"call_reason": "optional",
"first_message_override": "optional"
}
curl -X POST -H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"lead_id":"L1","agent_id":"A1","call_reason":"intro"}' \
https://YOUR-APP/api/v1/outreach/call
Response — 202
{
"data": {
"call_id": "...",
"task_id": "...",
"status": "queued"
}
}
The call is dialed by Cora's automation within ~1 minute.
Errors: no_phone_number (agent has no number), lead_no_phone, lead_stopped (lead opted out), resource_not_found.
POST/v1/outreach/emailsend a real email
Send a real email to a lead.
Body
lead_id— the lead to email (required).agent_id— the agent sending the email (required).subject— email subject (optional).message— email body (optional).template— template to use (optional).
{
"lead_id": "...",
"agent_id": "...",
"subject": "optional",
"message": "optional",
"template": "optional"
}
Response — 202
{
"data": {
"email_id": "...",
"task_id": "...",
"status": "queued"
}
}
Errors: agent_no_email, lead_no_email, lead_stopped.
Note: subject/message/template are accepted and stored, but the email worker currently composes the message itself — manual overrides take effect only once the email worker is updated to honor them.
Phone numbers
GET/v1/phone-numberslist phone numbers
Returns all phone numbers in the workspace. Not paginated (no limit/cursor/has_more), unlike the other list endpoints.
Response
{
"data": [
{
"id": "...",
"number": "...",
"outbound_agent_id": "...",
"inbound_agent_id": "...",
"country": "...",
"friendly_name": "...",
"status": "...",
"created_at": "..."
}
]
}
POST/v1/phone-numbers/{phone_id}/assignassign a phone number to an agent
Assigns a phone number to an agent in either an outbound or inbound role.
Body
agent_id— the agent to assign the phone number to.role— the assignment role (outbound|inbound).
{
"agent_id": "...",
"role": "outbound"
}
Stats
GET/v1/statsworkspace stats
Returns aggregate stats for the workspace.
Params
time_range— the time window (7d|30d|90d|all; v1 returns all-time).
Response
{
"total_leads": 0,
"total_calls": 0,
"total_emails": 0,
"meetings_booked": 0,
"converted": 0,
"agents": {
"outbound": 0,
"inbound": 0
}
}
API keys (managed in-app)
GET / POST /v1/api-keys and DELETE /v1/api-keys/{key_id} exist for the Cora UI and authenticate with a logged-in Firebase session, not an API key. Create and revoke keys in the dashboard → API tab.
Error codes
| code | status | meaning |
|---|---|---|
missing_api_key / invalid_api_key / revoked_api_key | 401 | auth problem with the key |
read_only_key | 403 | read-only key attempted a write |
rate_limit_exceeded | 429 | over 120 req/min (see retry_after) |
validation_error | 400 | bad/missing field (see param) |
missing_idempotency_key | 400 | outreach/call without Idempotency-Key |
resource_not_found | 404 | lead/agent/number/key not in your project |
idempotency_conflict | 409 | same key already in flight |
lead_no_phone / lead_no_email | 422 | lead lacks the needed contact field |
lead_stopped | 422 | lead opted out of outreach |
no_phone_number / agent_no_email / agent_inactive | 422 | agent isn't set up for that channel |
recording_unavailable | 404 | no recording exists for that call (recording may be off) |
internal_error | 500 | server error — retry later |
Notes & limits (v1)
- Data is isolated per project — a key only ever sees its own project's data.
list_leadsis unfiltered full-text on name/email/phone (prefix-ish substring match), not a search index.POST /v1/agentscreates the record only; voice/number provisioning is done in-app.- Transcripts are returned as
transcript_url, not parsed turn-by-turn.