# 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/json` for request bodies.
- **This doc (markdown):** `GET /api/v1/docs` · **Machine-readable index:** `GET /api/v1`

---

## Connect to Claude / Cursor (MCP) — recommended for AI agents

If you're an AI agent (Claude, Claude Cowork, Cursor, …), **connect through the Cora MCP server, not the raw REST API.** The connector uses **OAuth — you do NOT need an API key for it.** Every operation in this reference is exposed as an MCP tool.

**Connector URL:** `https://YOUR-APP/api/mcp`

**Three steps:**

1. Add Cora as a **custom connector** with the URL above.
   - **Claude Code:** `claude mcp add --transport http cora https://YOUR-APP/api/mcp`
   - **Claude Desktop / Cowork / Cursor:** Settings → Connectors → *Add custom connector* → paste the URL.
2. **Leave the "Advanced settings" empty** — do **not** fill in an OAuth Client ID or Secret. The server supports **Dynamic Client Registration (RFC 7591)**, so the client registers itself automatically.
3. A browser opens → **sign in with Cora and approve** → the tools light up across all your projects.

**OAuth details:** OAuth 2.1 with PKCE (S256) + Dynamic Client Registration; scopes `cora.read` and `cora.write` (a read-only consent grants `cora.read` only). Discovery: `GET /.well-known/oauth-authorization-server` and `GET /.well-known/oauth-protected-resource`.

**API key vs. connector — don't mix them up:** the `cora_live_…` API key is **only** for the CLI and direct REST calls (the `Authorization: Bearer` header below). It is **not** used by the Claude/Cursor connector — that's pure OAuth. MCP tool names match the operations here (e.g. `list_leads`, `create_lead`, `trigger_call`, `send_email`, `get_call_audio`, `get_stats`).
## Sign up & go live

You can self-provision an account and API key with **no human steps** — no sales call, no manual approval.

### 1. Create an account — `POST /v1/signup` (no auth, rate-limited)
Body:
```json
{ "email": "agent@example.com", "password": "optional", "company_name": "Acme", "name": "Jane", "source": "optional" }
```
Only `email` is required. Returns `201`:
```json
{ "api_key": "cora_live_...", "plans": [ ... ], "checkout_endpoint": "/v1/billing/checkout" }
```
```bash
curl -X POST -H "Content-Type: application/json" \
  -d '{"email":"agent@example.com","company_name":"Acme"}' \
  https://YOUR-APP/api/v1/signup
```
Save the `api_key` — it's shown once. Use it as `Authorization: Bearer cora_live_...` on every other endpoint.

### 2. Build for free, pay to send
Creating **agents**, **leads**, and configuration is **free**. Sending **real** outreach (calls/emails via `POST /v1/outreach/*`) returns `402 payment_required` until your project has an active subscription. Build and test the whole setup, then subscribe when you're ready to go live.

### 3. Subscribe — pick a plan and check out
1. `GET /v1/billing/plans` — list available subscription plans.
2. `POST /v1/billing/checkout` with `{ "plan": "starter|growth|pro", "interval": "monthly|annual" }` — returns `{ "checkout_url": "..." }`.
3. Open `checkout_url` (Stripe) to complete payment. Manage billing later via `POST /v1/billing/portal`.

Once subscribed, the `402` goes away and `POST /v1/outreach/call` / `POST /v1/outreach/email` place real calls and send real emails.

### 4. Connect via MCP (optional)
Prefer a connector over raw REST? Point an MCP client at `https://YOUR-APP/api/mcp`. Two ways to authenticate:
- **API key (headless):** send `Authorization: Bearer cora_live_...` (the key from `/v1/signup`). No consent screen — ideal for autonomous agents. Tools are scoped to that key's project.
- **OAuth (one approval):** connectors that don't take a static header start the OAuth flow — a human signs in and approves once.

Every REST endpoint above maps 1:1 to an MCP tool, **including billing**: `list_plans` and `create_checkout` let an agent fetch plans and produce the Stripe subscription link directly through the connector.

---

## 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/stats` for totals, `GET /v1/activity` for the recent event feed.
- **"Find a lead"** → `GET /v1/leads?query=jane`. Then `GET /v1/leads/{id}` for full detail incl. recent calls, emails, and activity.
- **"Add leads"** → `POST /v1/leads` (one) or `POST /v1/leads/bulk` (up to 500; duplicates by email are skipped).
- **"Reach out"** → `POST /v1/outreach/call` or `POST /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}`, then `POST /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:

```bash
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`).

```json
{ "data": { ... } }        // single object
{ "data": [ ... ], "has_more": false, "next_cursor": null }   // list
```

### Pagination

List endpoints return their items under `data` plus paging fields:

```json
{
  "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:

```json
{
  "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-Limit`
- `X-RateLimit-Remaining`
- `X-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

### Projects

#### `GET /v1/projects` — list projects
List the projects you can access (owned + your company's). MCP tool: `list_projects`.

**Response**
```json
{ "data": [ { "id": "...", "name": "...", "org_id": "..." } ] }
```

#### `POST /v1/projects` — create a project
Create a new project (workspace) in your company. Use the returned `id` as `project_id` on the other tools to build the rest of the flow (leads → agent → calls). MCP tool: `create_project`.

**Body**
- `name` — the project name.

**Response**
```json
{ "data": { "id": "...", "name": "...", "org_id": "..." } }
```

### Account

#### `GET /v1/credits` — remaining credits
Show the credits left on your account (credits fund phone numbers, calls, and outreach). MCP tool: `get_credits`.

**Response**
```json
{ "data": { "credits_remaining": 500, "unit": "credits" } }
```

### Leads

#### `GET /v1/leads` — list / 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.

```bash
curl -H "Authorization: Bearer $KEY" "https://YOUR-APP/api/v1/leads?query=acme&has_meeting=true&limit=20"
```

**Response** — each lead:
```json
{
  "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/leads` — create
Create a lead. Provide at least one of `name`, `first_name`, `last_name`, `company`.

**Body**
```json
{
  "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/bulk` — bulk create (≤ 500)
Create up to 500 leads at once. **Duplicates by email are skipped.**

**Body**
```json
{
  "leads": [
    { "name": "...", "email": "..." }
  ],
  "agent_id": "optional"
}
```

**Response**
```json
{
  "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}/calls` — list 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:

```json
{
  "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}/emails` — list 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:

```json
{
  "id": "...",
  "lead_id": "...",
  "agent_id": "...",
  "subject": "...",
  "body": "...",
  "summary": "...",
  "lead_email": "...",
  "status": "...",
  "workflow_id": "...",
  "created_at": "..."
}
```

#### `GET /v1/calls/{call_id}/audio` — fetch 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`).

```bash
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/activity` — recent 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:

```json
{
  "id": "...",
  "agent_id": "...",
  "agent_type": "...",
  "event_type": "...",
  "title": "...",
  "detail": "...",
  "status": "...",
  "lead_id": "...",
  "lead_name": "...",
  "metadata": {},
  "created_at": "..."
}
```

### Agents

#### GET /v1/agents — list
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:
```json
{
  "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:
```json
{
  "total_leads_assigned": 0,
  "total_calls": 0,
  "total_emails": 0
}
```

#### POST /v1/agents — create
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 of `call` | `email` | `both`.
- `language` — agent language.

```json
{
  "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 of `call` | `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`:
```json
{
  "lead_ids": ["id1", "id2"]
}
```

```json
{
  "assign_all_unassigned": true
}
```

**Response**
```json
{
  "data": {
    "assigned": 0,
    "total": 0
  }
}
```

### Outreach — real side effects

#### POST /v1/outreach/call — place 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).

```json
{
  "lead_id": "...",
  "agent_id": "...",
  "call_reason": "optional",
  "first_message_override": "optional"
}
```

```bash
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`

```json
{
  "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/email — send 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).

```json
{
  "lead_id": "...",
  "agent_id": "...",
  "subject": "optional",
  "message": "optional",
  "template": "optional"
}
```

**Response** — `202`

```json
{
  "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-numbers — list phone numbers
Returns all phone numbers in the workspace. Not paginated (no `limit`/`cursor`/`has_more`), unlike the other list endpoints.

**Response**

```json
{
  "data": [
    {
      "id": "...",
      "number": "...",
      "outbound_agent_id": "...",
      "inbound_agent_id": "...",
      "country": "...",
      "friendly_name": "...",
      "status": "...",
      "created_at": "..."
    }
  ]
}
```

#### POST /v1/phone-numbers/{phone_id}/assign — assign 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`).

```json
{
  "agent_id": "...",
  "role": "outbound"
}
```

### Stats

#### GET /v1/stats — workspace stats
Returns aggregate stats for the workspace.

**Params**
- `time_range` — the time window (`7d` | `30d` | `90d` | `all`; v1 returns all-time).

**Response**

```json
{
  "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_leads` is unfiltered full-text on name/email/phone (prefix-ish substring match), not a search index.
- `POST /v1/agents` creates the record only; voice/number provisioning is done in-app.
- Transcripts are returned as `transcript_url`, not parsed turn-by-turn.
