> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heymilo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# v2 API Overview

> What's new in the HeyMilo v2 Public API — base URL, auth, response shapes, and migration from Legacy API (v1).

<Note>
  **Looking for endpoint docs?** Browse the [v2 API Reference](/api-reference/interviews/get-full-interview-data) for every route. On Legacy API (v1)? See [Legacy API (v1)](/api-reference/legacy-v1/documentation).
</Note>

**Legacy API** (`https://api.heymilo.network`): the original HeyMilo HTTP surface, covering a handful of core actions around postings, ingestion, interviews, and webhooks.

**v2 API** (`https://api.heymilo.ai`): a REST-first redesign that exposes the full workspace, returns consistent typed envelopes, and is recommended for all new integrations.

***

## Key differences at a glance

|                         | **Legacy API**                                                                                   | **v2 API**                                                                                                          |
| ----------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| **Host**                | `https://api.heymilo.network`                                                                    | `https://api.heymilo.ai`                                                                                            |
| **Path prefix**         | `/api/...` (no version in path)                                                                  | `/api/v2/...`                                                                                                       |
| **URL style**           | Action verbs (`/api/postings/create`, `/api/webhook/fetch/{id}`, `/api/webhook/deactivate/{id}`) | REST resources (`POST /postings`, `GET /webhooks/{id}`, `DELETE /webhooks/{id}`)                                    |
| **Response shape**      | Raw resource, shape varies per route                                                             | Consistent envelope: `{ "data": ..., "meta"?: ..., "pagination"?: ... }`                                            |
| **Object type marker**  | None                                                                                             | Every resource carries `"object": "posting" \| "candidate" \| "interview_data" \| "webhook" \| ...`                 |
| **Error shape**         | `{ "detail": "string" }`                                                                         | `{ "error": { "type", "code", "message", "param?", "errors[]", "doc_url?" } }`                                      |
| **Validation errors**   | Opaque framework tree                                                                            | Flat `errors[]` with `{ code, message, param }` per offending field                                                 |
| **Pagination**          | `?page=1&limit=10` (offset-based, unstable under writes)                                         | `?limit=20&starting_after=<id>` + `pagination.has_more` + `pagination.total_count`                                  |
| **Partial updates**     | Full-body `POST`                                                                                 | `PATCH /postings/{id}`, only send the fields you want to change                                                     |
| **Async ingest**        | Same route and status as sync                                                                    | Dedicated `POST .../candidates/async` returning `202 Accepted` + `ingestion_id`                                     |
| **Deactivation**        | `POST /api/webhook/deactivate/{id}` returning `200`                                              | `DELETE /webhooks/{id}` returning the archived resource                                                             |
| **Create semantics**    | Returns `200` + bare resource                                                                    | Returns `201 Created` + full resource receipt (skip the follow-up `GET`)                                            |
| **Metadata**            | Ad-hoc, not validated                                                                            | Every resource: `metadata` key-value store with enforced limits                                                     |
| **Timestamps**          | Mixed ISO strings and epochs                                                                     | Unix epoch seconds (float) everywhere                                                                               |
| **Rate limiting**       | Keyed by client IP                                                                               | Keyed by API key (per-tenant, fair under NAT)                                                                       |
| **URL-key exposure**    | Ingestion key embedded in path (`/api/ingest/x/{url_key}`), leaks into logs                      | Ingestion scoped by posting ID, authenticated by your API key                                                       |
| **Interview results**   | Flat, ad-hoc blob                                                                                | Typed `interview_data` object with per-agent sub-resources (`web_interview`, `resume`, `sms`, `form`)               |
| **Workflow visibility** | Not exposed                                                                                      | First-class `workflow: [WorkflowStep]` on postings; per-step progress + `agent_summary` on candidates               |
| **Resource coverage**   | Postings, candidates, interviews, webhooks (partial)                                             | Full surface including questions, voices, phone numbers, sender emails, design templates, domains, schema discovery |
| **Schema discovery**    | Not available                                                                                    | `GET /schemas/agents`, `GET /schemas/question-types` (machine-readable registry)                                    |

***

## Base URL

All v2 endpoints live under a single host and path prefix:

<Card title="Production base URL" icon="globe">
  `https://api.heymilo.ai/api/v2`
</Card>

The legacy API remains available at `https://api.heymilo.network/api` for backward compatibility during migration.

```bash theme={null}
# v2 (recommended)
curl https://api.heymilo.ai/api/v2/postings \
  -H "X-API-KEY: sk_live_..."

# Legacy
curl https://api.heymilo.network/api/postings \
  -H "X-API-KEY: sk_live_..."
```

***

## Authentication

All requests require an API key in the `X-API-KEY` header.

```bash theme={null}
curl https://api.heymilo.ai/api/v2/postings \
  -H "X-API-KEY: sk_live_your_workspace_key"
```

<Note>
  **Why v2 auth is faster.** v2 validates keys once per request and caches the validated result, keyed by a SHA-256 hash of the key. On a cache hit, auth adds sub-millisecond overhead. Failures are explicit and consistent: `401` for a missing key, `403` for invalid or revoked, `503` when the auth layer is unavailable.
</Note>

***

## Response envelopes

Every v2 response is one of two shapes (single resource or list), so your parsing code stays identical across endpoints.

### Single resource

```json theme={null}
{
  "data": {
    "object": "posting",
    "id": "<posting-id>",
    "title": "Senior Software Engineer",
    "status": "active",
    "...": "..."
  },
  "meta": null
}
```

### List resource

```json theme={null}
{
  "data": [
    { "object": "posting", "id": "<posting-id>", "...": "..." },
    { "object": "posting", "id": "<posting-id>", "...": "..." }
  ],
  "pagination": {
    "has_more": true,
    "total_count": 147,
    "url": "/api/v2/postings"
  }
}
```

### Object type discriminator

Every resource carries an `object` field identifying its type. This lets you write one handler that dispatches by `object`, and it makes logs and debuggers trivially readable.

| Endpoint family                       | `object` value                                                               |
| ------------------------------------- | ---------------------------------------------------------------------------- |
| `/postings`                           | `"posting"`                                                                  |
| `/postings/{id}/candidates`           | `"candidate"`                                                                |
| `/postings/{id}/candidates/async`     | `"ingestion"`                                                                |
| `/interviews/{id}/data`               | `"interview_data"`                                                           |
| `/interviews/{id}/data` (sub-results) | `"web_interview_result" \| "resume_result" \| "sms_result" \| "form_result"` |
| `/webhooks`                           | `"webhook"`                                                                  |
| `/interviews/{id}/metadata`           | `"metadata"`                                                                 |

### Receipt pattern on creates

`POST /api/v2/postings` returns not just the new `id` and URLs but a full `posting` object nested under `data.posting`. Same for `POST /api/v2/postings/{id}/candidates` (full `candidate` under `data.candidate`). You can skip the redundant follow-up `GET`.

***

## Errors

Every v2 error uses the same envelope:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "validation_error",
    "message": "The request body failed validation.",
    "param": null,
    "doc_url": null,
    "errors": [
      {
        "code": "invalid_param",
        "message": "title must be between 3 and 200 characters",
        "param": "body.title"
      }
    ]
  }
}
```

| HTTP status | `error.code`          | When                                                         |
| ----------- | --------------------- | ------------------------------------------------------------ |
| `400`       | `bad_request`         | Malformed request (bad JSON, missing required header)        |
| `401`       | `unauthorized`        | Missing `X-API-KEY`                                          |
| `403`       | `forbidden`           | Invalid or revoked API key                                   |
| `404`       | `not_found`           | Resource does not exist or does not belong to your workspace |
| `409`       | `conflict`            | Duplicate or conflicting request                             |
| `422`       | `validation_error`    | Request body validated but failed field-level checks         |
| `429`       | `rate_limit_exceeded` | Too many requests (see [Rate limiting](#rate-limiting))      |
| `5xx`       | `api_error`           | Unexpected server-side failure                               |

The legacy API returned `{"detail": "some string"}`, sometimes a string, sometimes a nested framework tree on 422s. v2 always returns the same envelope, always includes a machine-readable `type` and `code`, and for 422s includes a flat `errors[]` list that maps 1:1 to offending fields (`body.title`, `body.workflow[0].config.voice_id`, and so on).

***

## Pagination

List endpoints in v2 use **cursor pagination**. This is more resilient to concurrent writes than the page/offset model the legacy API used.

**Query parameters**

* `limit`: max page size, `1` to `100`, default `20`.
* `starting_after`: the `id` of the last item from the previous page (use the `id` field of the last element in `data[]`).

**Response pagination block**

```json theme={null}
"pagination": {
  "has_more": true,
  "total_count": 147,
  "url": "/api/v2/postings"
}
```

**Paginate all postings**

```bash theme={null}
# Page 1
curl "https://api.heymilo.ai/api/v2/postings?limit=100" \
  -H "X-API-KEY: sk_live_..."

# Page 2: pass the id of the last item from page 1
curl "https://api.heymilo.ai/api/v2/postings?limit=100&starting_after=<last-posting-id>" \
  -H "X-API-KEY: sk_live_..."
```

Stop when `pagination.has_more` is `false`.

**Why cursor beats offset**

* Stable under concurrent writes: new postings created during a paginated scan won't cause you to skip or re-see items.
* Constant-time regardless of how deep pagination goes.
* Matches the convention used by every modern developer-facing API.

***

## Metadata

Every first-class v2 resource (`posting`, `candidate`, `interview`) supports a typed `metadata` object you can use to stash your own identifiers (ATS IDs, external source tags, correlation IDs) without waiting for us to add a bespoke field.

<Note>
  **Limits (enforced server-side)**

  * Up to **50** keys per resource.
  * Each key ≤ **40** characters.
  * Each value ≤ **500** characters.
  * All values must be **strings** (send numbers and booleans as strings).
</Note>

**Set metadata**

```bash theme={null}
curl -X POST https://api.heymilo.ai/api/v2/interviews/<interview-id>/metadata \
  -H "X-API-KEY: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "metadata": {
      "external_id": "<your-external-id>",
      "source": "<your-source-system>"
    }
  }'
```

**Get metadata**

```bash theme={null}
curl https://api.heymilo.ai/api/v2/interviews/<interview-id>/metadata \
  -H "X-API-KEY: sk_live_..."
```

**Delete metadata**

```bash theme={null}
curl -X DELETE https://api.heymilo.ai/api/v2/interviews/<interview-id>/metadata \
  -H "X-API-KEY: sk_live_..."
```

Validation errors surface as structured `422` responses with `param: "metadata.<key>"` so your integration knows exactly which entry to fix.

***

## Timestamps

Every v2 timestamp field is a **Unix epoch float (seconds since 1970-01-01T00:00:00Z)**. Fields with null or pending states are `null`, never empty strings.

```json theme={null}
{
  "created_at": 1739612400.0,
  "updated_at": 1739617200.0,
  "interviewed_at": null
}
```

The legacy API mixed ISO strings (`"2025-02-15T14:00:00Z"`), epoch integers, and occasional `null`s depending on the route. v2 normalises this across the surface.

***

## Rate limiting

v2 rate limits **per API key**. The legacy API rate-limited per client IP, which penalised customers whose outbound traffic was NAT-ed behind a single egress (typical for server-to-server integrations).

* Default window is configurable per environment; production limits are published in your workspace's developer portal.

* On exceed, v2 returns `429` with:

  ```json theme={null}
  {
    "error": {
      "type": "rate_limit_error",
      "code": "rate_limit_exceeded",
      "message": "Rate limit exceeded: 100 per minute"
    }
  }
  ```

* Retry with exponential backoff; honour `Retry-After` headers when present.

***

## HTTP semantics

v2 follows the common REST conventions customers expect. Proxies, API gateways, and OpenAPI tooling all behave better when status codes and verbs are used correctly.

| Operation                  | Legacy                                              | v2                                                                     |
| -------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- |
| Create a posting           | `POST /api/postings/create` returning `200`         | `POST /api/v2/postings` returning `201 Created`                        |
| Read a posting             | `GET /api/postings` (with filters)                  | `GET /api/v2/postings/{id}`                                            |
| Update a posting (partial) | Full-body `POST`                                    | `PATCH /api/v2/postings/{id}` (partial)                                |
| Archive a posting          | Side-effect of update                               | `POST /api/v2/postings/{id}/archive`                                   |
| Activate a posting         | Implicit on create                                  | `POST /api/v2/postings/{id}/activate`                                  |
| Clone a posting            | `POST /api/postings/clone` returning `200`          | `POST /api/v2/postings/{id}/clone` returning `201`                     |
| Async ingest a candidate   | Same route as sync, `200`                           | `POST /api/v2/postings/{id}/candidates/async` returning `202 Accepted` |
| Deactivate a webhook       | `POST /api/webhook/deactivate/{id}` returning `200` | `DELETE /api/v2/webhooks/{id}`                                         |

`201` on creates, `202` on async, `PATCH` for partial updates, `DELETE` for deactivation: small changes, big quality-of-life improvement for anyone writing a client.

***

## Resource coverage

v2 exposes the full workspace surface area, not just the handful of verbs the legacy API shipped. Every resource uses the envelopes, errors, pagination, and metadata conventions above.

| Area                           | v2 endpoint prefix                                    | Legacy coverage                  |
| ------------------------------ | ----------------------------------------------------- | -------------------------------- |
| **Interviewers** (postings)    | `/api/v2/postings`                                    | Create + clone only              |
| **Candidates**                 | `/api/v2/postings/{id}/candidates`                    | Mixed (per-URL-key)              |
| **Interviews & results**       | `/api/v2/interviews/{id}/data`                        | Single flat endpoint             |
| **Interview metadata**         | `/api/v2/interviews/{id}/metadata`                    | Not exposed                      |
| **Questions (all modalities)** | `/api/v2/postings/{id}/questions` + `/reorder`        | Not exposed                      |
| **Webhooks**                   | `/api/v2/webhooks`                                    | Partial (create/list/deactivate) |
| **Voices** (AI voice registry) | `/api/v2/voices`                                      | Not exposed                      |
| **Phone numbers**              | `/api/v2/phone-numbers`                               | Not exposed                      |
| **Sender emails**              | `/api/v2/sender-emails`                               | Not exposed                      |
| **Email templates**            | `/api/v2/email-templates`, `/email-template-groups`   | Not exposed                      |
| **Design templates**           | `/api/v2/design-templates`, `/design-template-groups` | Not exposed                      |
| **Custom domains**             | `/api/v2/domains`                                     | Not exposed                      |
| **Schema discovery**           | `/api/v2/schemas/agents`, `/schemas/question-types`   | Not exposed                      |

**Highlights**

* **Questions / criteria CRUD.** Full create, read, update, delete, and reorder for every modality (`voice`, `sms`, `form`, `resume_eligibility`, `resume_scoring`, `voice_tags`). Build question-editing UIs entirely against the API.
* **Interview results hierarchy.** `GET /interviews/{id}/data` returns a typed `interview_data` object with `web_interview`, `resume`, `sms`, and `form` sub-results populated for whichever agents the interviewer's workflow includes. No more guessing which fields will be present.
* **Agentic workflow as a first-class object.** Postings expose an ordered `workflow: [WorkflowStep]` array. Each step has `id`, `type`, `order`, and typed `config`. Candidate responses carry per-step progress (`workflow.steps[]`) and `agent_summary` (resume score, interview score, SMS eligibility, form status).
* **Schema discovery.** `GET /api/v2/schemas/agents` returns the registry of supported agent types, their config fields, and which question modalities they support. `GET /api/v2/schemas/question-types` does the same for questions. This lets partners build dynamic UIs without hard-coding our product taxonomy.

***

## Endpoint mapping (legacy → v2)

This is the mapping most integrations will reach for during migration. All legacy paths are relative to `https://api.heymilo.network`; all v2 paths are relative to `https://api.heymilo.ai`.

### Postings (Interviewers)

| Legacy                                                         | v2                                         |
| -------------------------------------------------------------- | ------------------------------------------ |
| `POST /api/postings/create`                                    | `POST /api/v2/postings`                    |
| `POST /api/postings/clone`                                     | `POST /api/v2/postings/{id}/clone`         |
| `GET  /api/postings` (paginated via `?page&limit`)             | `GET  /api/v2/postings` (cursor-paginated) |
| `GET  /api/interviews/{posting_id}` (candidates for a posting) | `GET  /api/v2/postings/{id}/candidates`    |
| *no equivalent*                                                | `GET  /api/v2/postings/{id}`               |
| *no equivalent*                                                | `PATCH /api/v2/postings/{id}`              |
| *no equivalent*                                                | `POST /api/v2/postings/{id}/archive`       |
| *no equivalent*                                                | `POST /api/v2/postings/{id}/activate`      |

### Candidates & ingestion

| Legacy                                              | v2                                                                      |
| --------------------------------------------------- | ----------------------------------------------------------------------- |
| `POST /api/ingest/x/{url_key}`                      | `POST /api/v2/postings/{posting_id}/candidates`                         |
| `POST /api/ingest/x/bulk/{url_key}`                 | `POST /api/v2/postings/{posting_id}/candidates/bulk`                    |
| *no equivalent*                                     | `POST /api/v2/postings/{posting_id}/candidates/async` (`202`)           |
| *no equivalent*                                     | `POST /api/v2/postings/{posting_id}/candidates/bulk/async`              |
| `GET  /api/candidates/{posting_id}`                 | `GET  /api/v2/postings/{posting_id}/candidates`                         |
| `POST /api/candidates/{posting_id}` (filtered list) | `GET  /api/v2/postings/{posting_id}/candidates` (with pagination query) |

<Note>
  **Security improvement.** v2 intentionally drops URL keys from paths. The legacy API required you to embed the ingestion URL key into every request URL (`/api/ingest/x/{url_key}`), which caused them to leak into proxy logs, browser referrers, and CDN caches. v2 scopes ingestion by posting ID, which is already authenticated by your API key.
</Note>

### Interviews

| Legacy                                                | v2                                                    |
| ----------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/interview/{interview_id}`                   | `GET /api/v2/interviews/{interview_id}/data`          |
| `GET /api/interviews?posting_id=...&candidate_id=...` | Use `GET /api/v2/postings/{id}/candidates` and filter |
| *no equivalent*                                       | `GET    /api/v2/interviews/{id}/metadata`             |
| *no equivalent*                                       | `POST   /api/v2/interviews/{id}/metadata`             |
| *no equivalent*                                       | `DELETE /api/v2/interviews/{id}/metadata`             |

### Webhooks

| Legacy                                      | v2                             |
| ------------------------------------------- | ------------------------------ |
| `POST /api/webhook/create`                  | `POST   /api/v2/webhooks`      |
| `GET  /api/webhook/all`                     | `GET    /api/v2/webhooks`      |
| `GET  /api/webhook/fetch/{webhook_id}`      | `GET    /api/v2/webhooks/{id}` |
| `POST /api/webhook/deactivate/{webhook_id}` | `DELETE /api/v2/webhooks/{id}` |

### New in v2 (no legacy equivalent)

* `GET /api/v2/postings/{id}/questions`, `POST /api/v2/postings/{id}/questions`, `PATCH/DELETE .../questions/{qid}`, `POST .../questions/reorder`: full question and criteria CRUD across all modalities.
* `GET /api/v2/voices`, `/phone-numbers`, `/sender-emails`, `/email-templates`, `/email-template-groups`, `/design-templates`, `/design-template-groups`, `/domains`: workspace resource discovery.
* `GET /api/v2/schemas/agents`, `GET /api/v2/schemas/question-types`: self-describing schema for building dynamic UIs.

***

## Appendix: full v2 endpoint list

All paths are relative to `https://api.heymilo.ai/api/v2`.

### Interviewers (`/postings`)

| Method  | Path                      | Summary                 |
| ------- | ------------------------- | ----------------------- |
| `POST`  | `/postings`               | Create an interviewer   |
| `GET`   | `/postings`               | List interviewers       |
| `GET`   | `/postings/{id}`          | Get an interviewer      |
| `PATCH` | `/postings/{id}`          | Update an interviewer   |
| `POST`  | `/postings/{id}/archive`  | Archive an interviewer  |
| `POST`  | `/postings/{id}/activate` | Activate an interviewer |
| `POST`  | `/postings/{id}/clone`    | Clone an interviewer    |

### Candidates (`/postings/{posting_id}/candidates`)

| Method | Path                                   | Summary                            |
| ------ | -------------------------------------- | ---------------------------------- |
| `POST` | `/postings/{id}/candidates`            | Ingest a single candidate          |
| `POST` | `/postings/{id}/candidates/bulk`       | Bulk ingest candidates             |
| `POST` | `/postings/{id}/candidates/async`      | Async ingest a single candidate    |
| `POST` | `/postings/{id}/candidates/bulk/async` | Async bulk ingest candidates       |
| `GET`  | `/postings/{id}/candidates`            | List candidates for an interviewer |

### Interviews (`/interviews`)

| Method   | Path                        | Summary                                                            |
| -------- | --------------------------- | ------------------------------------------------------------------ |
| `GET`    | `/interviews/{id}/data`     | Get full interview data (scorecard, transcript, resume, SMS, form) |
| `GET`    | `/interviews/{id}/metadata` | Get interview metadata                                             |
| `POST`   | `/interviews/{id}/metadata` | Set interview metadata                                             |
| `DELETE` | `/interviews/{id}/metadata` | Delete interview metadata                                          |

### Questions (`/postings/{posting_id}/questions`)

| Method   | Path                                     | Summary                                 |
| -------- | ---------------------------------------- | --------------------------------------- |
| `GET`    | `/postings/{id}/questions`               | List questions (filterable by modality) |
| `GET`    | `/postings/{id}/questions/{question_id}` | Retrieve a question                     |
| `POST`   | `/postings/{id}/questions`               | Create a question                       |
| `PATCH`  | `/postings/{id}/questions/{question_id}` | Update a question                       |
| `DELETE` | `/postings/{id}/questions/{question_id}` | Delete a question                       |
| `POST`   | `/postings/{id}/questions/reorder`       | Reorder questions                       |

### Webhooks (`/webhooks`)

| Method   | Path             | Summary              |
| -------- | ---------------- | -------------------- |
| `POST`   | `/webhooks`      | Register a webhook   |
| `GET`    | `/webhooks`      | List webhooks        |
| `GET`    | `/webhooks/{id}` | Get webhook details  |
| `DELETE` | `/webhooks/{id}` | Deactivate a webhook |

### Workspace resources

| Method | Path                      | Summary                              |
| ------ | ------------------------- | ------------------------------------ |
| `GET`  | `/voices`                 | List available AI interviewer voices |
| `GET`  | `/phone-numbers`          | List provisioned phone numbers       |
| `GET`  | `/sender-emails`          | List sender email addresses          |
| `GET`  | `/email-templates`        | List email templates                 |
| `GET`  | `/email-template-groups`  | List email template groups           |
| `GET`  | `/design-templates`       | List interview UI design templates   |
| `GET`  | `/design-template-groups` | List design template groups          |
| `GET`  | `/domains`                | List custom domains                  |

### Schema discovery

| Method | Path                      | Summary                            |
| ------ | ------------------------- | ---------------------------------- |
| `GET`  | `/schemas/agents`         | List agent types and their configs |
| `GET`  | `/schemas/question-types` | List question types by modality    |
