# Everlit Articles API

Version: 1.0.0
Last Updated: 2026-09-01

Publish a complete Everlit audio article from a URL or from text: we fetch and
extract the page (or take your title and body), narrate it in the
publication's configured voice with byline, disclaimer, and intro/outro music,
and publish it to a public player you can link to or embed. The same pipeline
the Everlit widget and Studio use — driven from code, or from an AI agent over
MCP.

**Base URL:** `https://api.everlit.audio`

---

## Quickstart

```bash
# 0. Which publication? (one-time — the id is stable)
curl https://api.everlit.audio/v1/publications \
  -H "Authorization: Bearer api_YOUR_KEY"
# => { "object": "list", "data": [ { "id": "pblc_abc123", "name": "The Daily", ... } ] }

# 1. Create an article from a URL
curl -X POST https://api.everlit.audio/v1/articles \
  -H "Authorization: Bearer api_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"publication":"pblc_abc123","url":"https://example.com/news/city-budget"}'
# => 202
# {
#   "object": "article", "id": "art_01jx...", "status": "queued",
#   "url": "https://example.com/news/city-budget", "title": "City approves budget", ...
# }

# 2. Poll until status is "succeeded" (typically 1-5 minutes)
curl https://api.everlit.audio/v1/articles/art_01jx... \
  -H "Authorization: Bearer api_YOUR_KEY"
# => { ..., "status": "succeeded", "article_id": "artl_9f3k...",
#      "player_url": "https://everlit.audio/embeds/artl_9f3k...",
#      "embed_html": "<iframe src=\"https://everlit.audio/embeds/artl_9f3k...\" ...></iframe>",
#      "duration_seconds": 254.3 }
```

Or send the content yourself — no fetch, deterministic input:

```bash
curl -X POST https://api.everlit.audio/v1/articles \
  -H "Authorization: Bearer api_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "publication": "pblc_abc123",
    "url": "https://example.com/news/city-budget",
    "title": "City approves budget",
    "text": "The city council voted 7-2 on Tuesday to approve...",
    "authors": ["Jane Doe"],
    "published_at": "2026-09-01T10:30:00Z"
  }'
```

---

## Client examples

**Python**

```python
import time, requests

API = "https://api.everlit.audio/v1"
H = {"Authorization": "Bearer api_YOUR_KEY"}

pub = requests.get(f"{API}/publications", headers=H).json()["data"][0]["id"]
job = requests.post(f"{API}/articles", headers=H,
                    json={"publication": pub, "url": "https://example.com/news/city-budget"}).json()

while job["status"] in ("queued", "processing"):
    time.sleep(15)
    job = requests.get(f"{API}/articles/{job['id']}", headers=H).json()

if job["status"] == "succeeded":
    print(job["player_url"])
    print(job["embed_html"])
else:
    print("failed:", job["error"])
```

**Node**

```js
const API = "https://api.everlit.audio/v1";
const H = { Authorization: "Bearer api_YOUR_KEY", "Content-Type": "application/json" };

let job = await (await fetch(`${API}/articles`, {
  method: "POST", headers: H,
  body: JSON.stringify({ publication: "pblc_abc123", url: "https://example.com/news/city-budget" }),
})).json();

while (["queued", "processing"].includes(job.status)) {
  await new Promise(r => setTimeout(r, 15000));
  job = await (await fetch(`${API}/articles/${job.id}`, { headers: H })).json();
}
console.log(job.status === "succeeded" ? job.player_url : job.error);
```

---

## Authentication

Every request needs an `api_` key as a bearer token:

```
Authorization: Bearer api_...
```

Keys are minted in Studio under **API → Keys** and are scoped to your
publisher account. They are server-side credentials — never embed one in a
browser or mobile app (for reader-facing pages, use the
[Auto Audio widget](/docs/auto-audio) instead, which needs no key).

Any paid Studio plan can mint an `api_` key. A key may additionally be
restricted to specific publications; those keys only see and publish into
their publications. Unlike the TTS API, the Articles API needs no
pay-as-you-go card: articles count against your plan's monthly article
quota, exactly as widget- and Studio-created articles do. (The same key
calls the TTS API only once pay-as-you-go billing is set up; until then TTS
requests answer `403 tts_api_not_enabled`.)

---

## Endpoints

| Method | Path | Purpose |
|---|---|---|
| `POST` | `/v1/articles` | Create an article → `202` (or `200` when it already exists) |
| `GET` | `/v1/articles/{id}` | Fetch an article job (poll this) |
| `GET` | `/v1/articles` | List article jobs, newest first (`limit`, `starting_after`, `status`, `publication`, `q`) |
| `DELETE` | `/v1/articles/{id}` | Delete the article, or cancel a running conversion → `204` |
| `GET` | `/v1/publications` | The publications this key can publish into |
| `GET` | `/v1/voices` | Voices available to your account (shared with the [TTS API](/docs/tts-api#voices)) |
| `POST` | `/v1/mcp` | MCP server for AI agents — see [MCP](#mcp) |

## Create article

`POST /v1/articles`

Send **`url`** (we fetch and extract the page) **or `title` + `text`** (we use
your content verbatim and never fetch). When both are sent, `url` is only the
article's canonical identifier: it dedupes repeat requests and is what the
player links back to.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `publication` | string | yes | Publication id (`pblc_...`) from `GET /v1/publications`. Supplies the voice, music, disclaimer and player defaults. |
| `url` | string | * | Public `http(s)` URL of the article. |
| `title` | string | * | Headline. With `text`, skips fetching. |
| `text` | string | * | Article body, plain text, up to 200,000 characters. Markup is stripped. |
| `authors` | string[] | no | Author names; read aloud as the byline and added as `author:` tags. |
| `summary` | string | no | Description shown on the player. |
| `category` | string | no | Section name; becomes a `category:` tag. |
| `tags` | string[] | no | Extra tags (e.g. tickers). |
| `art_url` | string | no | Cover image URL. Extracted from the page when omitted. |
| `published_at` | string | no | ISO 8601. Defaults to the page's date, else now. A future time schedules the article: it stays unlisted until then. |
| `custom_byline` | string | no | Exact byline text to read instead of the generated "A and B". |
| `voice` | string | no | Narrator voice id (`GET /v1/voices`). Default: the publication's voice for the detected language. |
| `guest_voice` | string | no | Second voice for conversation mode. |
| `conversation_mode` | boolean | no | Two-voice conversational narration. |
| `mix` | boolean | no | Apply the publication's intro/outro music beds. Default `true`. |
| `intro_mixable` / `outro_mixable` | string | no | Mixable ids overriding the publication's beds. |
| `read_urls` / `read_alt_text` | boolean | no | Read URLs / image alt text aloud. Default: publication setting. |
| `read_author` | boolean | no | Read the byline aloud. Default: publication setting (usually `true`). |
| `sonic_optimizer` | boolean | no | Audio Polish pass. Default: publication setting. |
| `disclaimer` / `disclaimer_voice` | string | no | Override the publication's AI disclaimer text / voice. |
| `regenerate` | boolean | no | Re-narrate even if an article for this `url` already exists (see [Existing articles](#existing-articles)). |
| `callback_url` | string | no | `https://` URL to POST the finished article to. |
| `metadata` | object | no | Up to 8 string values (512 bytes total), echoed back on the job. |

Unknown parameters are rejected with `400 unknown_parameter` — the API is
strict so that future additions are never breaking.

When `url` is given without `text`, the page is fetched during the request
(a few seconds). A page we cannot fetch or that has no article body returns
`422 content_unavailable` / `422 no_content` — send `title` and `text` instead.

### The article resource

Returned by every endpoint and delivered to your callback — one shape:

```json
{
  "object": "article",
  "id": "art_01jx...",
  "status": "succeeded",
  "created_at": "2026-09-01T14:00:00Z",
  "completed_at": "2026-09-01T14:03:12Z",
  "publication": "pblc_abc123",
  "url": "https://example.com/news/city-budget",
  "title": "City approves budget",
  "language": "en",
  "voice": "109",
  "characters": 4812,
  "article_id": "artl_9f3k...",
  "player_url": "https://everlit.audio/embeds/artl_9f3k...",
  "embed_html": "<iframe title=\"Everlit Audio Player\" src=\"https://everlit.audio/embeds/artl_9f3k...\" width=\"100%\" height=\"136px\" ...></iframe>",
  "duration_seconds": 254.3,
  "regenerate": false,
  "deleted_at": null,
  "callback_url": null,
  "error": null,
  "metadata": { "ref": "cms-4512" }
}
```

- `id` (`art_...`) is this conversion job. `article_id` (`artl_...`) is the
  published Everlit article — the durable thing your readers listen to,
  present once `status` is `succeeded`.
- `player_url` is the public player page; `embed_html` is the same player as
  an iframe for your site. Both stay valid for the life of the article.
- `status` is one of `queued`, `processing`, `succeeded`, `failed`,
  `deleted`. Poll every 15–30 seconds; a typical article completes in 1–5
  minutes (longer for very long pieces). `error.code` explains a failure.

## Existing articles

`url` is the identity of an article within a publication. If a completed
article already exists for it — created by the API, the widget on your
site, RSS ingestion, or Studio — `POST /v1/articles` returns it immediately
with `200`, `status: "succeeded"`, and its `player_url`, without narrating
again or spending quota. If a conversion for that URL is already running,
you get that running job back (`200`, `status: "queued"` or `"processing"`).

Pass `regenerate: true` to re-narrate: the article keeps its `article_id`
and player URL, and the new audio replaces the old when it finishes. Use it
when the page's text changed or the voice settings did. Text-only requests
(no `url`) always create a new article.

## Deleting articles

`DELETE /v1/articles/{id}` answers `204` and does one of two things:

- **A finished job:** the published article is taken down — `player_url`
  and `embed_html` stop working, the article disappears from Studio and
  from any playlists it was in. Other jobs that returned the same
  `article_id` (see [Existing articles](#existing-articles)) are marked
  deleted with it.
- **A running job** (`queued` or `processing`): the conversion is cancelled
  and no article is published. Cancelling a `regenerate: true` job keeps
  the existing article and its old audio.

The job record survives with `status: "deleted"` and a `deleted_at`
timestamp, so `GET /v1/articles/{id}` and the list still show it (filter
with `status=deleted`). Deleting is idempotent and never refunds the
month's article quota. Deleting an article does not stop the Auto Audio
widget from narrating that page again the next time readers open it — remove
the widget from the page, or delete the article from Studio and set the
page to skip, if that is what you want.

## Publications

`GET /v1/publications`

```json
{
  "object": "list",
  "data": [
    {
      "object": "publication",
      "id": "pblc_abc123",
      "name": "The Daily",
      "url": "https://example.com",
      "default_voice": "109",
      "language_voices": ["english", "spanish"]
    }
  ],
  "has_more": false
}
```

A publication carries everything an article inherits: default narrator per
language, guest voice, intro/outro beds, disclaimer text and voice, reading
toggles, and the player's look. Configure them in Studio under
**Publications → Auto Audio**; the API only overrides what you pass.

---

## Quotas and rate limits

- **Articles per month** follow your plan, shared with the widget and Studio.
  Over the cap, `POST /v1/articles` answers `403 article_limit_reached`.
  Requests that converge onto an existing article never spend quota.
- **Requests**: 60 creates and 300 reads per minute per key, with
  `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` headers on
  every response and `Retry-After` on a `429`.
- **Capacity**: when the conversion queue is saturated you get
  `503 capacity_unavailable` with `Retry-After` — our capacity, not your
  behaviour; retry after the delay.
- **Per URL**: one conversion at a time. A second request while one is in
  flight returns the running job; a concurrent widget-triggered conversion
  answers `409 conversion_in_progress`.

---

## Idempotency

Pass an `Idempotency-Key` header (any unique string, e.g. a UUID) on
`POST /v1/articles` to make retries safe: resubmitting the same key with the
same body returns the original job (`200` + `Idempotency-Replayed: true`)
without creating another. The same key with a *different* body is rejected
with `409 idempotency_key_reuse`. Keys are scoped to your account. For
URL-based requests the URL itself already dedupes (see
[Existing articles](#existing-articles)); the key matters most for text-only
input.

---

## Webhooks

Pass `callback_url` and we POST the full article resource to it when the
job reaches a terminal state, with header `X-Everlit-Event:
article.succeeded` or `article.failed`. Deliveries retry with backoff for
~30 minutes on non-2xx responses. Only `https://` URLs are accepted.

Deliveries are signed exactly like TTS API webhooks — `X-Everlit-Signature:
t=<unix>,v1=<hmac>` over `"<t>.<body>"` with your webhook secret from
**Studio → API → Usage**. See the [TTS API webhook section](/docs/tts-api#webhooks)
for verification code; one verifier handles both event families.

---

## MCP

The Articles API is part of Everlit's [MCP](https://modelcontextprotocol.io)
server, so Claude, Cursor, Claude Code, and any other MCP-capable agent can
publish audio articles directly. The server also hosts the
[TTS API tools](/docs/tts-api#mcp); one connection, one key.

| Tool | Backs |
|---|---|
| `list_publications` | `GET /v1/publications` |
| `create_article` | `POST /v1/articles` (accepts an `idempotency_key` argument) |
| `get_article` | `GET /v1/articles/{id}` |
| `list_articles` | `GET /v1/articles` |
| `list_voices` | `GET /v1/voices` |

**Endpoint:** `https://api.everlit.audio/v1/mcp` — Streamable HTTP transport,
stateless (no session ids), JSON responses. Authenticate every request with
the same `Authorization: Bearer api_...` header; there is no OAuth flow.

```json
{
  "mcpServers": {
    "everlit": {
      "type": "http",
      "url": "https://api.everlit.audio/v1/mcp",
      "headers": { "Authorization": "Bearer api_YOUR_KEY" }
    }
  }
}
```

```bash
claude mcp add --transport http everlit https://api.everlit.audio/v1/mcp \
  --header "Authorization: Bearer api_YOUR_KEY"
```

A typical agent session: `list_publications` → `create_article` with the
page URL the user gave → `get_article` until `succeeded` → hand back
`player_url`. There is no blocking "wait" tool: while a job is `queued` or
`processing`, `create_article` and `get_article` results carry
`poll_after_seconds` (30), and the server instructions tell agents to wait
that long between calls. When an article for the URL already exists the tool
returns it with `converged: true` and no conversion runs.

Tool calls are admitted and limited exactly like the REST endpoints — they
run the same code. A failed call returns an MCP tool error (`isError: true`)
whose `structuredContent.error` is the same envelope documented under
[Errors](#errors), plus `http_status` and, on `429`/`503`, `retry_after`
seconds.

### Tools

Every tool takes a JSON object of arguments and returns the same JSON the
matching REST endpoint would, as `structuredContent` (and pretty-printed as
text). `create_article` and `get_article` add `poll_after_seconds` while the
job is still running.

<!-- mcp-tools:start (generated by `rake docs:tts_api`; do not edit by hand) -->

### `create_article`

Publish a full Everlit audio article from a web page (url) or from text (title + text): narration in the publication's configured voice, intro/outro music, byline, disclaimer, and a public player. Returns the article job (status "queued"); poll get_article (honoring poll_after_seconds) until status is "succeeded", then use player_url / embed_html. If an article for the url already exists it is returned immediately with status "succeeded" — pass regenerate: true only to re-narrate. Counts against the monthly plan article quota.

| Argument | Type | Required | Description |
|---|---|---|---|
| `publication` | string | yes | The publication id (pblc_...) from list_publications. |
| `url` | string | no | Public http(s) URL of the article to narrate. We fetch and extract it. Required unless title and text are given. |
| `title` | string | no | Article headline (with text, skips fetching the url). |
| `text` | string | no | Article body to narrate, plain text (up to 200,000 characters). |
| `authors` | array | no | Author names, read aloud as the byline. |
| `summary` | string | no | Short description shown on the player. |
| `category` | string | no | Section/category name. |
| `tags` | array | no | Extra tags. |
| `art_url` | string | no | Cover image URL. |
| `published_at` | string | no | ISO 8601 publication time; defaults to now. |
| `voice` | string or integer | no | Narrator voice id from list_voices. Defaults to the publication's voice for the detected language. |
| `conversation_mode` | boolean | no | Two-voice conversational narration. |
| `mix` | boolean | no | Apply the publication's intro/outro music beds (default true). |
| `regenerate` | boolean | no | Re-narrate even if an article for this url already exists. |
| `callback_url` | string | no | Optional https:// URL to POST the finished article to. |
| `metadata` | object | no | Up to 8 string values (512 bytes total), echoed back on the article. |
| `idempotency_key` | string | no | Any unique string; resubmitting the same key and arguments returns the original article job. |

### `get_article`

Fetch an article job by id. status is one of queued, processing, succeeded, failed, deleted. While queued/processing the result includes poll_after_seconds: wait that long before checking again. When succeeded, player_url is the public player page and embed_html an iframe for a website.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The article job id (art_...). |

### `list_articles`

List this account's article jobs, newest first. Paginate with starting_after = the last id of the previous page while has_more is true.

| Argument | Type | Required | Description |
|---|---|---|---|
| `limit` | integer | no | Page size (default 20). |
| `starting_after` | string | no | Return jobs created before this job id. |
| `status` | enum: queued, processing, succeeded, failed, deleted | no | Filter by status. |
| `publication` | string | no | Filter by publication id. |
| `q` | string | no | Free-text match on id, url, title, article id, error code, or metadata. |

### `list_publications`

Publications this key can publish articles into, with each one's site URL and default voice. Use the id as create_article's publication.

_No arguments._

### `delete_article`

Delete an article: cancels it if still queued/processing, otherwise takes the published player and embed down. The job record stays with status "deleted". Quota already used is not refunded.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The article job id (art_...). |

<!-- mcp-tools:end -->

---

## Errors

Errors use one envelope, with a stable machine-readable `code` — branch on
`code`, never on `message` text:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "content_unavailable",
    "message": "The article could not be fetched from that URL. Check that it is public, or send title and text instead.",
    "param": "url",
    "request_id": "ab12cd34"
  }
}
```

Include `request_id` in support tickets.

| HTTP | code | Meaning |
|---|---|---|
| 400 | `invalid_json` | Body is not a JSON object |
| 400 | `missing_parameter` | `publication` absent, or neither `url` nor `title`+`text` given |
| 400 | `unknown_parameter` | Unrecognized parameter (check spelling) |
| 400 | `invalid_parameter` | Wrong type or value (arrays, booleans, `published_at`, voice/mixable access) |
| 400 | `invalid_url` | `url` / `art_url` is not an http(s) URL |
| 400 | `invalid_encoding` / `text_too_long` | `text` is not UTF-8 / over 200,000 characters |
| 400 | `invalid_callback_url` | Must be https with a hostname |
| 401 | `missing_api_key` / `invalid_api_key` / `api_key_revoked` / `api_key_expired` | Credential problems |
| 403 | `wrong_key_type` | Use an `api_` key (not `pk_`/`tkn_`) |
| 403 | `article_limit_reached` | Monthly plan quota used up |
| 403 | `voice_not_accessible` / `mixable_not_accessible` | Belongs to another account |
| 404 | `publication_not_found` | Not on your account (or outside this key's publication scope) |
| 404 | `article_not_found` / `voice_not_found` / `mixable_not_found` / `unknown_endpoint` | Nothing at that address |
| 409 | `conversion_in_progress` | The URL is being converted by another request; retry after `Retry-After` |
| 409 | `idempotency_key_reuse` | Same key, different body |
| 422 | `content_unavailable` | The page could not be fetched |
| 422 | `no_content` / `no_speakable_text` | Nothing narratable on the page / in `text` |
| 429 | `rate_limit_exceeded` / `read_rate_limit_exceeded` | Slow down (see `Retry-After`) |
| 500 | `internal_error` | Our fault |
| 503 | `capacity_unavailable` | Temporarily saturated; retry |

A job that fails after being accepted reports it on the resource instead:
`status: "failed"` with `error.code` one of `no_content`, `generation_failed`,
`mixing_failed`, `storage_error`, `publish_failed`, `article_failed`,
`timeout`, `internal_error`. Failed jobs never spend quota.

---

## Changelog

**1.1.0 — 2026-09-02**
- `DELETE /v1/articles/{id}` and the `delete_article` MCP tool: soft-delete a published article or cancel a running conversion. New `deleted` status and `deleted_at` field on the article resource.

**1.0.0 — 2026-09-01**
- Initial release: `POST /v1/articles`, `GET /v1/articles/{id}`, `GET /v1/articles`, `GET /v1/publications`, webhooks, idempotency, and the `create_article` / `get_article` / `list_articles` / `list_publications` MCP tools on the shared Everlit MCP server.

---

## Support

- Email: support@everlit.audio
- OpenAPI spec: https://everlit.audio/docs/tts-openapi.yml (shared with the TTS API)
- Status: https://status.everlit.audio
