# Everlit Articles API

Version: 1.9.0
Last Updated: 2026-09-11

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": null, "job_id": "ajob_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/ajob_01jx... \
  -H "Authorization: Bearer api_YOUR_KEY"
# => { ..., "status": "succeeded", "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 by its id (`artl_...`) or by `job_id` (`ajob_...`) (poll this) |
| `GET` | `/v1/articles` | List articles, newest first (`limit`, `starting_after`, `status`, `publication`, `q`, `ids`, `idempotency_key`, `source`) |
| `PATCH` | `/v1/articles/{id}` | Change title, summary, tags, cover, publish time or privacy → `200` |
| `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/publications/{id}` | One publication and the [settings](#publication-settings) its articles inherit |
| `PATCH` | `/v1/publications/{id}` | Change those settings |
| `GET` | `/v1/voices` | Voices available to your account (shared with the [TTS API](/docs/tts-api#voices)) |
| `GET` | `/v1/mixables` | Music beds — see [Music beds](#music-beds) |
| `POST` | `/v1/mixables` | Upload a music bed of your own → `201` |
| `GET` | `/v1/mixables/{id}` | One music bed |
| `DELETE` | `/v1/mixables/{id}` | Delete one of your own beds → `204` |
| `GET` | `/v1/playlists` | Playlists in this account — see [Playlists](#playlists) |
| `POST` | `/v1/playlists` | Create a playlist → `201` |
| `GET` | `/v1/playlists/{id}` | One playlist with its resolved tracks and pins |
| `PATCH` | `/v1/playlists/{id}` | Change the playlist's rules |
| `DELETE` | `/v1/playlists/{id}` | Delete the playlist → `204` |
| `POST` | `/v1/playlists/{id}/articles` | Pin an article to a playlist → `201` |
| `PATCH` | `/v1/playlists/{id}/articles/{article}` | Move a pinned article |
| `DELETE` | `/v1/playlists/{id}/articles/{article}` | Unpin an article |
| `GET` | `/v1/playlists/{id}/feed` | The playlist's podcast feed — see [Podcast feeds](#podcast-feeds) |
| `PATCH` | `/v1/playlists/{id}/feed` | Set the podcast metadata |
| `POST` | `/v1/playlists/{id}/feed/activate` | Publish the feed |
| `POST` | `/v1/playlists/{id}/feed/deactivate` | Stop publishing it |
| `GET` | `/v1/playlists/{id}/feed/platforms` | Where the show stands on each directory — see [Directories](#directories) |
| `POST` | `/v1/playlists/{id}/feed/platforms/{platform}/submitted` | Record that the show was submitted there |
| `POST` | `/v1/playlists/{id}/feed/platforms/{platform}/live` | Record that the directory listed it |
| `DELETE` | `/v1/playlists/{id}/feed/platforms/{platform}` | Clear what was recorded for one directory → `204` |
| `POST` | `/v1/playlists/{id}/feed/ping` | Ask Overcast to re-crawl the feed now |
| `GET` | `/v1/pronunciations` | The pronunciation library — see [Pronunciations](#pronunciations) |
| `PUT` | `/v1/pronunciations` | Add or update a pronunciation rule |
| `DELETE` | `/v1/pronunciations/{word}` | Remove a pronunciation rule |
| `GET` | `/v1/ingestion-feeds` | RSS/Atom feeds narrated automatically — see [Ingestion feeds](#ingestion-feeds) |
| `POST` | `/v1/ingestion-feeds` | Start ingesting a feed → `201` |
| `GET` | `/v1/ingestion-feeds/{id}` | One ingestion feed |
| `PATCH` | `/v1/ingestion-feeds/{id}` | Change its interval, caps, name or status |
| `DELETE` | `/v1/ingestion-feeds/{id}` | Stop ingesting and delete it → `204` |
| `POST` | `/v1/ingestion-feeds/{id}/poll` | Check the feed now |
| `GET` | `/v1/ingestion-feeds/{id}/items` | Items the feed has seen and the articles they became |
| `POST` | `/v1/mcp` | MCP server for AI agents — see [MCP](#mcp) |

`GET /v1/articles?idempotency_key=K` finds a job again if you lose its id
before recording it (crash recovery, same as the TTS API); `?ids=artl_a,artl_b`
(up to 50) fetches many articles you already know the ids of in one call.
`?source=` chooses jobs, Studio articles, or both — see
[Any article, any origin](#any-article-any-origin).

## 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. May carry [inline markup](/docs/tts-api#inline-markup): `[everlit-pause:1.5]` for an explicit pause, `[everlit-mixable:N]` / `[everlit-mixable-background:N]` for a music bed, `[everlit-subvoice-open:N]`…`[everlit-subvoice-close]` for a passage in a second voice; other Everlit control sequences are 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). |
| `audio_url` | string | no | `https` URL of your own recording to publish instead of narrating — see [Pre-recorded audio](#pre-recorded-audio). Requires `title`. |
| `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, used when `podcast_mode` is on. |
| `language` | string | no | Language tag (`en-US`, `es-MX`) to narrate in, skipping detection. The publication's voice and disclaimer for that language are used. |
| `podcast_mode` | boolean | no | Two-voice conversational narration. |
| `music` | boolean | no | Mix the intro/outro music beds into the audio. Default `true`. |
| `intro_music` / `outro_music` | string | no | The bed to play, overriding the publication's: a mixable id, `"shuffle"` for a random track from the publication's intro/outro music, or `""` for none. |
| `intro_music_duration` / `outro_music_duration` | number | no | Seconds of bed before / after the narration. |
| `intro_music_pad` / `outro_music_pad` | number | no | Seconds the bed overlaps the narration. |
| `read_urls` / `read_alt_text` | boolean | no | Read URLs / image alt text aloud. Default: publication setting. |
| `read_title` | boolean | no | Read the title aloud at the start of the audio. Default: publication setting (usually `true`). |
| `read_author` | boolean | no | Read the byline aloud. Default: publication setting (usually `true`). |
| `audio_polish` | boolean | no | Audio Polish pass — rewrite the text for the ear before narrating. Default: publication setting. |
| `audio_polish_level` | string | no | How far Audio Polish may rewrite: `"0"`, `"1"`, `"2"`, `"3"`. Default: publication setting, else `"2"`. |
| `disclaimer` / `disclaimer_voice` | string | no | Override the spoken AI disclaimer text / voice for this article. `disclaimer: ""` narrates none. The strings `"false"`, `"null"`, `"none"` and `"off"` are rejected with `400 invalid_parameter` so they are never read aloud. |
| `ui_disclaimer` | boolean | no | Show the AI disclosure line under the player. Default: publication setting. |
| `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. |

Every audio field above is the same name a publication uses for its own
default — see [Publication settings](#publication-settings). The names this
API launched with (`mix`, `intro_mixable`, `outro_mixable`, `sonic_optimizer`,
`conversation_mode`) are permanently accepted as aliases; sending both
spellings of one setting with different values is a `400`. A validation error
names the field with the spelling you sent — send `music` and the error's
`param` is `music`, send `mix` and it is `mix`.

**Blank vs. omitted.** This rule holds everywhere in this API, on articles and
on publications:

- **omitted, or `null`** — no opinion. The publication default applies,
  nothing stored is changed, and on a `regenerate` the article keeps the
  settings it was last narrated with.
- **`""` (empty string)** — an explicit *off*. These are the only fields with
  an off state:

  | Field | `""` means |
  |---|---|
  | `disclaimer` (article) | narrate no spoken disclaimer |
  | `voices.{lang}.disclaimer` (publication) | narrate none for that language |
  | `ui_disclaimers.{lang}` (publication) | show no disclosure line for that language |
  | `intro_music` / `outro_music` (both) | play no bed |
  | `language` (publication) | go back to detecting the language per article |
  | `player.title_intro` / `player.title_icon` (publication) | show none |

  Anywhere else `""` is not an off switch: a voice, an enum or a boolean
  rejects it with `400`, and a blank number of seconds (or
  `audio_polish_level`) is treated exactly like `null`. Publication `pauses`
  are the same — `""` there means *use our default*, because a pause has no
  off: `0` is a real, explicit value.

  For the music beds `""` and *omitted* really are different: omitting
  `intro_music` lets us pick a bed and omitting `outro_music` uses the
  publication's, while `""` means silence.

**The music beds take a third value.** `intro_music` and `outro_music` each
accept a mixable id, the literal `"shuffle"` — a random track from the
publication's intro/outro music, picked fresh for every narration and never
recorded on the article — or `""` for no bed at all. A publication reads its
beds back in those same three forms, so the `defaults` object from a `GET`
`PATCH`es straight back whatever state it is in. An intro with no track set
reads as `"shuffle"`, because that is what it plays; an outro with none reads
as `""`.

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.

### Pre-recorded audio

Send `audio_url` — an `https` link to your own MP3, WAV or M4A — and nothing is
narrated: we fetch the file, transcribe it for read-along, mix the
publication's intro/outro beds around it exactly as `music`, `intro_music` and
`outro_music` say, and publish it as an ordinary audio article with a player.

- `title` is **required**. `text` is optional — when you leave it out, the
  transcript of your recording becomes the article body. `url` is still the
  canonical link and the identity of the article (see
  [Existing articles](#existing-articles)).
- The narration parameters do not apply and are rejected with `400
  invalid_parameter`: `voice`, `guest_voice`, `language`, `podcast_mode`,
  `audio_polish`, `audio_polish_level`, `read_urls`, `read_alt_text`,
  `read_title`, `read_author`, `custom_byline`, `disclaimer`, `disclaimer_voice` (and their
  aliases). Everything else — the music beds and their durations and pads,
  `ui_disclaimer`, `authors`, `summary`, `category`, `tags`, `art_url`,
  `published_at`, `callback_url`, `metadata`, `publication` — is accepted.
- The file must be at most 500MB and served over `https` from a public host.
  Every redirect is re-checked, so a link that ends up at a private address is
  refused with `400 invalid_url`.
- On the finished article, `characters` and `voice` are `null` — nothing was
  synthesized, and no plan characters are spent (the article still counts
  against the monthly article quota). `duration_seconds` is the length of the
  **mixed** file, beds included.
- The spoken AI disclaimer is never narrated over your recording, and the ID3
  tags carry the AI disclosure as **false**. The disclosure line under the
  player follows `ui_disclaimer` when you send it, and is hidden when you do
  not — the same as an upload made in Studio.
- `source` is `api`, as for any other article this API created.

```bash
curl https://api.everlit.audio/v1/articles \
  -H "Authorization: Bearer $EVERLIT_API_KEY" -H "Content-Type: application/json" \
  -d '{"publication":"pblc_abc123","title":"Episode 12: The bridge","url":"https://example.com/podcast/12","audio_url":"https://cdn.example.com/episodes/12.mp3","authors":["Ana Ruiz"]}'
# => 202 { "object": "article", "job_id": "ajob_01jx...", "status": "queued", "characters": null, ... }
```

### Inline markup

`text` takes the same inline markup as the TTS API — the tags Studio and the
WordPress plugin write — so text copied from either narrates the same way:
`[everlit-pause:N]`, `[everlit-mixable:N]`, `[everlit-mixable-background:N]`
and `[everlit-subvoice-open:N]`…`[everlit-subvoice-close]`. The vocabulary,
the casing rules and the examples live in one place: [Inline
markup](/docs/tts-api#inline-markup) in the TTS guide.

- Every id is checked against your account before the article is created: a
  malformed tag or an unbalanced subvoice block is `400 invalid_markup`, a bed
  or voice belonging to someone else is `403 mixable_not_accessible` /
  `403 voice_not_accessible`, and an id that does not exist is a `404`. The
  error's `param` is `text`.
- Tags are not narrated and are never counted in `characters`.
- They apply to narrated text only. On an `audio_url` article `text` is the
  article body, not a script, so markup there is left exactly as you sent it.
- `[Everlit-Sponsored:N]` and any other Everlit control sequence are still
  stripped, from `text` and from `title`, `summary`, `custom_byline` and
  `disclaimer`, which take no inline markup of their own.
- Audio Polish leaves the tags where you put them.

### The article resource

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

```json
{
  "object": "article",
  "id": "artl_9f3k...",
  "job_id": "ajob_01jx...",
  "status": "succeeded",
  "source": "api",
  "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,
  "markup": null,
  "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" },
  "published_at": "2026-09-01T14:03:12Z",
  "privacy": null,
  "tags": [],
  "summary": null,
  "authors": [],
  "cover": null
}
```

- `id` (`artl_...`) is the published Everlit article — the durable thing your
  readers listen to. It is `null` until the article exists, which for a new
  conversion means when `status` becomes `succeeded`.
- `job_id` (`ajob_...`) is the conversion job this API ran, returned by
  `POST /v1/articles` and usable as `{id}` on every route while `id` is still
  `null`. It is `null` on an article this API did not convert.
- `markup` counts the [inline markup](#inline-markup) in the text we
  narrated (`pause_tags`, `pause_seconds`, `mixable_tags`,
  `subvoice_tags`), and is `null` when there was none.
- `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.
- `source` is `api` for a job created here and `studio` for everything else
  in the account (the widget, WordPress, RSS, Studio itself). A `studio` row
  has no `job_id`, no `characters`, and its `created_at`
  is nothing more than its `published_at`; `privacy`, `tags`, `summary`,
  `authors` and `cover` come from the published article and are empty on a
  job that has not published one.

## Any article, any origin

Your Studio holds more than this API created: pages the Auto Audio widget
narrated, WordPress posts, RSS items, uploads made in Studio. All of them are
readable, listable and deletable here by their `artl_` article id.

- `GET /v1/articles/{id}` and `DELETE /v1/articles/{id}` take an article id or
  a `job_id`. An `artl_` id that this API also has a job for is answered from
  the job (it knows `characters`, `callback_url`, `error`); otherwise you get
  the Studio article, with `source: "studio"`. Anything that is neither an
  `artl_...` id nor a job id is a `400`.
- `GET /v1/articles?source=` chooses what a page holds: `all` (the default)
  is every Studio article plus the jobs Studio cannot have — one that never
  published, and the failed and deleted ones — newest first; `api` is only
  the jobs this API created; `studio` is only what Studio holds. Any other
  value is a `400` `invalid_source`.
- `starting_after` takes either form and means "older than this article", by
  publication time. `ids` may mix the two forms in one call; an `artl_` id
  this key cannot see is skipped rather than failing the page.
- A publication-scoped key sees only articles in its own publications,
  whatever created them. Another account's article is a `404`, never a `403`.

## 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 `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.

A regenerate starts from the settings the article was last narrated with;
anything this request sends explicitly overrides them, under the same
[blank vs. omitted](#create-article) rule.

## Update article

`PATCH /v1/articles/{id}` changes what an article *says about itself* — its
title, summary, tags, cover, publish time and visibility — without
re-narrating a word of it. It takes an article id (`artl_...`) or a `job_id`
(`ajob_...`) and reaches any article in your Studio, whatever created it (see
[Any article, any origin](#any-article-any-origin)). Only the fields you send
change; omitting one, or sending `null`, leaves it alone.

| Parameter | Type | Description |
|---|---|---|
| `title` | string | Headline. Cannot be blank; at most 500 characters. |
| `summary` | string | Description shown on the player. `""` clears it. |
| `authors` | string[] | The complete byline; replaces the article's authors (at most 20). |
| `tags` | string[] | Replaces the article's tags (at most 50). |
| `privacy` | string | `"public"` or `"unlisted"`. |
| `published_at` | string | ISO 8601 publish time. |
| `art_url` | string | Cover image URL. |
| `metadata` | object | Up to 8 string values (512 bytes total). Only on an article this API created — a `400` otherwise. |

**Unlisted and scheduled.** `privacy: "unlisted"` keeps the player and the
embed working but hides the article from playlists, feeds, roundups and
listings. A `published_at` in the future *schedules* the article: unless you
also send `privacy`, it is set to `unlisted` and flipped public at that time.
A `published_at` in the past or present leaves `privacy` untouched.

The answer is the same [article resource](#the-article-resource) every other
endpoint returns. An article that is still converting (`queued` /
`processing`) has nothing published to edit yet and answers `409
job_not_complete`; a deleted one answers `409 article_deleted`.

```bash
curl -X PATCH https://api.everlit.audio/v1/articles/artl_9f3k... \
  -H "Authorization: Bearer $EVERLIT_API_KEY" -H "Content-Type: application/json" \
  -d '{"title":"City approves budget, 7-2","tags":["city hall","budget"],"privacy":"unlisted"}'
# => 200 { "object": "article", "id": "artl_9f3k...", "title": "City approves budget, 7-2", "privacy": "unlisted", ... }
```

## Deleting articles

`DELETE /v1/articles/{id}` answers `204` and does one of two things. It takes
an article id (`artl_...`) or a `job_id` (`ajob_...`), so it also takes down an
article Studio, the widget, WordPress or RSS made — see
[Any article, any origin](#any-article-any-origin).

- **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 (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
}
```

`default_voice` is the narrator English content gets; `language_voices` lists
every language the publication configures a voice for. Everything else a
publication hands its articles is in [Publication
settings](#publication-settings).

## Publication settings

`GET /v1/publications/{id}` returns one publication with everything its
articles inherit, and `PATCH /v1/publications/{id}` changes it. These apply to
every article the publication produces — the API, the Auto Audio widget on
your site, RSS ingestion and Studio alike — so change them deliberately; to
affect one article only, pass the audio fields to `POST /v1/articles`.

```json
{
  "object": "publication",
  "id": "pblc_abc123",
  "name": "The Daily",
  "url": "https://example.com",
  "language_voices": ["en-US", "es-MX"],
  "default_voice": "109",
  "defaults": {
    "language": "en-US",
    "voices": {
      "en-US": { "voice": "109", "guest_voice": "115", "disclaimer": "This audio was generated by AI.", "disclaimer_voice": "112" },
      "es-MX": { "voice": "204", "guest_voice": null, "disclaimer": "Audio generado por IA.", "disclaimer_voice": null }
    },
    "author_voices": [{ "author": "Jane Doe", "voice": "118" }],
    "ui_disclaimers": { "en-US": "Narrated by AI." },
    "music": true,
    "intro_music": "34",
    "outro_music": "35",
    "intro_music_duration": 15,
    "outro_music_duration": 15,
    "intro_music_pad": 5,
    "outro_music_pad": 4,
    "audio_polish": true,
    "audio_polish_level": "2",
    "podcast_mode": false,
    "read_urls": false,
    "read_alt_text": false,
    "read_title": true,
    "read_author": true,
    "player": {
      "size": "large", "cover_art": true, "title_intro": "Listen Now:", "title_icon": "headphones",
      "theme": {
        "colors": { "accent": "#1a2b3c", "background": "#ffffff" },
        "fonts": { "title": { "src": "https://example.com/fonts/tiempos.woff2", "size_adjust": 100 } }
      }
    },
    "studio": { "read_along": true },
    "sharing": { "enabled": true, "link": "canonical" },
    "email_capture": { "enabled": false, "label": null, "prompt": null },
    "pauses": { "title": 0.5, "subtitle": [0.4, 0.6], "byline": null, "disclaimer": null, "header_to_body": null },
    "extraction": {
      "content_selector": ".article-body",
      "title_selector": null,
      "date_selector": null,
      "exclude_selectors": [".share-widget", ".newsletter-signup"],
      "tag_field_map": { "section": "category" },
      "site_name_tag_prefix": null,
      "tag_namespaces": ["author", "category", "language"]
    },
    "widget": {
      "allowed_origins": ["example.com"],
      "click_to_create": false,
      "click_to_create_message": null,
      "generate_on_first_view": false,
      "rss_only": false
    },
    "paywall": {
      "enabled": false,
      "access_attribute": "everlit-access",
      "cta_text": null,
      "cta_url": null,
      "button_label": null,
      "button_color": null,
      "button_color_enabled": false
    }
  }
}
```

`PATCH` takes any subset, follows the same
[blank vs. omitted](#create-article) rule, and returns the updated publication
in the shape above. Unknown fields are a `400 unknown_parameter` at any depth
(`player.colour`, `voices.en-US.accent`).

### Voices

Voices, spoken disclaimers and the voice that reads them are configured **per
language**, because that is how narration is chosen: we detect the article's
language (or take the `language` you send) and use that language's entry.

| Field | Type | Description |
|---|---|---|
| `voices` | object | Keyed by language tag (`en-US`, `es-MX`, `es`, or the catch-all `default`). Each value is `{ voice, guest_voice, disclaimer, disclaimer_voice }`. |
| `voices.{lang}.voice` | string | Narrator voice id for this language. Cannot be blank. |
| `voices.{lang}.guest_voice` | string | The second voice in `podcast_mode` for this language. Configured exactly like `voice`, and like it cannot be blank: leaving it unset means a voice is chosen when a dialogue is actually produced, which is not the same as "no guest". |
| `voices.{lang}.disclaimer` | string | Spoken AI disclaimer. `""` narrates none for this language. |
| `voices.{lang}.disclaimer_voice` | string | Voice that reads it. `""` only while that language's disclaimer is blank too. |
| `language` | string | The tag to narrate in without detecting, e.g. `"en-US"`. Must be a key of `voices`; `""` goes back to detecting per article. |
| `author_voices` | array | `[{ "author": "Jane Doe", "voice": "118" }]` — a byline's own voice, which **beats** the language voice. Author names match ignoring case. |
| `ui_disclaimers` | object | Per language: the AI disclosure line shown *under* the player (the spoken one is `voices.{lang}.disclaimer`). `""` shows none for that language; `null` drops the language. |

A `voices` or `ui_disclaimers` patch **merges language by language**: languages
you do not mention are untouched, and within a language, fields you do not
mention are untouched. Send `"es-MX": null` to remove that language entirely
(voice, disclaimer and disclaimer voice together). `author_voices` is the
exception — it is a list, so sending it **replaces the whole list**, and `[]`
clears it.

Language tags are canonicalized (`es-mx` → `es-MX`), the same normalization
the Auto Audio widget applies, so both surfaces read each other's settings.

Two notes on older publications. A publication configured before per-language
voices existed reports its single voice, guest voice and disclaimer under `en-US` — where
narration actually falls back to them, and where they migrate to. And a
language whose voice is a legacy *rotation* (several ids we pick from) reports
the first id; writing `voice` replaces the rotation with the one voice you
send.

### Audio

| Field | Type | Description |
|---|---|---|
| `music` | boolean | Shorthand for turning both `intro_music` and `outro_music` off (`false`) or back on (`true`). It is derived, not stored: reading it is "at least one bed plays", and writing it leaves the bed ids alone, so `music: true` puts the same beds back. Sending it in the same request as an `intro_music` / `outro_music` that disagrees is a `400`. |
| `intro_music` / `outro_music` | string | The bed every article gets: a mixable id, `"shuffle"` for a random track from your intro/outro music, or `""` for none. Setting an id clears a shuffle, and a shuffle keeps the id, so turning the shuffle off puts the same bed back. |
| `intro_music_duration` / `outro_music_duration` | number | Seconds of bed before / after the narration. |
| `intro_music_pad` / `outro_music_pad` | number | Seconds the bed overlaps the narration. |
| `audio_polish` | boolean | Rewrite the text for the ear before narrating. |
| `audio_polish_level` | string | How far it may rewrite: `"0"` (symbols and numbers only), `"1"`, `"2"`, `"3"` (free rewrite). Default `"2"`. |
| `podcast_mode` | boolean | Two-voice conversational narration, using `guest_voice`. |
| `read_urls` / `read_alt_text` | boolean | Read URLs / image alt text aloud. |
| `read_title` | boolean | Read the title aloud at the start of the audio. |
| `read_author` | boolean | Read the byline aloud. |
| `pauses` | object | Silence after `title`, `subtitle`, `byline`, `disclaimer`, `header_to_body`. Each is seconds as a number, or `[min, max]` for a range we pick from per article. |

Music is always mixed at our end: the player is handed one finished, pre-mixed
file, so the only music switch is which beds play, not whether we mix.

#### Music beds

`GET /v1/mixables` lists the intro/outro beds `intro_music` and `outro_music`
(here and on `POST /v1/articles`) may reference — Everlit's library plus
anything assigned to your publisher — so you never have to guess an id and
read the `404` back:

```json
{
  "object": "list",
  "data": [
    { "id": "42", "name": "Calm Intro", "kind": "intro", "tags": { "mood": "calm" },
      "owned_by": "everlit", "preview_url": "https://cdn.everlit.audio/mixables/calm-intro.mp3" }
  ],
  "has_more": false
}
```

| Field | Description |
|---|---|
| `id` | Pass this as `intro_music` / `outro_music`. |
| `name` | Display name (the filename without its extension). |
| `kind` | As stored on the bed (`intro`, `outro`, ...). |
| `tags` | Free-form tags on the bed (mood, genre, ...). |
| `owned_by` | `everlit` (global library) or `publisher` (assigned to your account only). |
| `preview_url` | A clip you can hand a person, or `null`. |

Query params: `kind` (filter, e.g. `?kind=intro`), `q` (free-text on name or
tags), `limit`, `starting_after`. No TTS entitlement is required — it is
plan-based like the rest of the Articles API.

**Your own beds.** `POST /v1/mixables` adds a track to your account: we fetch
the audio from `url` and store it, and the bed it returns can be used as
`intro_music` / `outro_music` straight away. Everlit's own library is never
changed — `owned_by: "everlit"` rows are read-only for every account.

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | Display name, 1–120 characters. Becomes the bed's filename with the fetched file's extension. |
| `url` | string | yes | `https://` URL of the audio file. Fetched by our servers: `mp3`, `wav`, `m4a`, `ogg` or `flac`, at most 25 MB, no IP-literal or private hosts, at most 3 redirects. |
| `kind` | string | no | `intro-outro` (a music bed — the default), `sfx` (a sound effect) or `ambient`. |
| `tags` | object | no | `tempo`, `subjects` and `attributes`, each an array of up to 20 strings of at most 40 characters. Any other key is a `400`. |
| `static_duration` | boolean | no | `true` when the track must play in full instead of being trimmed to the configured seconds. |
| `publications` | string[] | no | Publication ids (`pblc_...`) this bed is limited to. Omit (or `[]`) to make it usable across the whole account. |

The response is the same row the list returns, plus a `Location` header. The
bed's Studio dropdown label is set for you (`tags.display_kind`: `Music` for
`intro-outro`, `Sound Effects` for `sfx`, `Ambient` for `ambient`).

`GET /v1/mixables/{id}` reads one bed — yours or one of Everlit's.
`DELETE /v1/mixables/{id}` deletes a bed **you** uploaded and clears it from
any publication still pointing at it (`204`). An Everlit library track is a
`403 not_owned`; a bed on another account is a `404`, like any id you cannot
see.

```bash
curl -X POST https://api.everlit.audio/v1/mixables \
  -H "Authorization: Bearer $EVERLIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Upbeat Intro",
    "url": "https://cdn.example.com/beds/upbeat-intro.mp3",
    "kind": "intro-outro",
    "tags": { "tempo": ["upbeat"], "subjects": ["news"], "attributes": ["energetic"] }
  }'
```

### Player and page

| Field | Type | Description |
|---|---|---|
| `player.size` | string | `large` or `small`. |
| `player.floating` | string | Auto Audio only. Once the reader has pressed play, pin the player to the viewport while they scroll past it: `bottom-right`, `bottom-left`, `bottom`, `top-right`, `top-left`, `top`, or `off` (default). Readers can dismiss it for the page. |
| `player.floating_start` | string | How the floating article player first appears: `full` (default) or `minimized` (the small pill). |
| `player.floating_playlist_start` | string | How a floating playlist first appears: `minimized` (default) or `full`. |
| `player.floating_size` | string | Width of the floating card in a corner: `large` (640px), `medium` (468px, default) or `small` (336px). Never wider than the player on the page. |
| `player.floating_mobile` | boolean | Also float on phones, tablets and in-app webviews. Default `false`: the lock screen controls playback there. |
| `player.cover_art` | boolean | Show the article's cover art in the player. |
| `player.title_intro` | string | Line above the title, e.g. `"Listen Now:"`. `""` shows none. |
| `player.title_icon` | string | Icon beside the title (`"headphones"`). `""` shows none. |
| `studio.read_along` | boolean | Highlight words during preview playback in Studio. |
| `sharing.enabled` | boolean | Show the share button on the player. |
| `sharing.link` | string | Which URL it shares: `canonical`, `canonical_hosted` or `hosted`. |
| `email_capture.enabled` | boolean | Ask listeners for an email address. |
| `email_capture.label` | string | Button label, at most 30 characters. |
| `email_capture.prompt` | string | Prompt text, at most 120 characters. |

#### Theme

`player.theme` is the player's design — the same colours and fonts Studio's
**Player design** screen paints the embed with.

| Field | Type | Description |
|---|---|---|
| `player.theme.colors` | object | Named colours, each a hex string like `"#1a2b3c"`. The names: `background`, `accent`, `primary`, `title_text`, `body_text`, `border`, `cover_background`, `control_background`, `control_icon`, `progress_bar`, `progress_bar_active`, `progress_handle`, `playlist_tracks_background`, `playlist_tracks_border`. |
| `player.theme.fonts` | object | `title` and `body`, each `{ "src": "https://…/font.woff2", "size_adjust": 100 }`. `src` must be an **https** URL ending in `.woff2`, `.woff`, `.ttf` or `.otf`; `size_adjust` is a CSS size-adjust percentage between 50 and 200 (default `100`). |

The theme **merges key by key**, unlike the lists elsewhere in this API: a
colour or font you do not mention is left exactly as it was. Send `""` for one
colour, or `""` in place of a whole font object, to put that one back to its
derived default. Colours that are not `#rrggbb` are a `400`, and a colour set
directly on the publication outside these fourteen names is preserved.

### Page extraction

How the Auto Audio widget reads a page. Everything here is an override: leave
it unset and articles are extracted automatically, which is right for nearly
every site. Reach for it when a page's furniture is being narrated, or the
wrong element is picked as the body.

| Field | Type | Description |
|---|---|---|
| `extraction.content_selector` | string | CSS selector for the article body when automatic extraction picks the wrong element. Every match is read, in page order. `""` goes back to automatic extraction. |
| `extraction.title_selector` | string | CSS selector for the headline, when the page has no usable title metadata. |
| `extraction.date_selector` | string | CSS selector for the publication date, same. |
| `extraction.exclude_selectors` | array of strings | Page furniture never to narrate — share widgets, newsletter sign-ups, related-story rails. At most 100 entries, each at most 500 characters. |
| `extraction.tag_field_map` | object | Page field name → tag prefix, e.g. `{"section": "category"}`: which of the page's own metadata fields become article tags, and how each is prefixed. At most 50 entries. |
| `extraction.site_name_tag_prefix` | string | When set (e.g. `"site"`), every article gets a `"<prefix>:<site name>"` tag taken from the page. `""` adds none. |
| `extraction.tag_namespaces` | array of strings | Tag namespaces the public playlist tag picker offers: any of `author`, `category`, `language`, `content`. Anything else is a `400`. |

The three lists and the map **replace** what is stored rather than merging into
it, so send the whole list you want. `[]` (or `{}`) clears one: no exclusions,
no tag fields, no namespaces offered.

### Widget behaviour

When and where the Auto Audio widget narrates.

| Field | Type | Description |
|---|---|---|
| `widget.allowed_origins` | array of strings | Hostnames the widget may narrate pages from, e.g. `["example.com", "www.example.com"]`. Entries are hostnames only — a scheme or a path is a `400`. `[]` allows every page the widget runs on. Replaces the whole list. |
| `widget.click_to_create` | boolean | Wait for a reader to press play before narrating a page that has no audio yet, instead of narrating it on the first view. |
| `widget.click_to_create_message` | string | Message shown while that first narration is being made. `""` shows the default. |
| `widget.generate_on_first_view` | boolean | Narrate an older article on its first pageview instead of waiting for it to draw repeat traffic. For back catalogues. |
| `widget.rss_only` | boolean | Only narrate articles that arrive through an RSS feed; the widget narrates nothing on its own. |

### Paywall

Gate the player behind your paywall. Readers without access hear nothing and
see the call to action instead.

| Field | Type | Description |
|---|---|---|
| `paywall.enabled` | boolean | Turn the gate on. |
| `paywall.access_attribute` | string | The HTML attribute your page sets on a subscriber to say they have access. Defaults to `everlit-access`. |
| `paywall.cta_text` | string | The line shown to a reader without access, e.g. `"Subscribe today to listen to this content"`. |
| `paywall.cta_url` | string | Where the button sends them — an `http(s)` URL. `""` removes the link. |
| `paywall.button_label` | string | Label on that button, e.g. `"Get Access"`. |
| `paywall.button_color` | string | Button colour as `#rrggbb`. Used only when `button_color_enabled` is true. |
| `paywall.button_color_enabled` | boolean | Use `button_color` instead of the player's own colours. |

Every string field here follows the usual rule: omit it (or send `null`) to
leave it alone, and send `""` to clear it.

```bash
curl -X PATCH https://api.everlit.audio/v1/publications/pblc_abc123 \
  -H "Authorization: Bearer $EVERLIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "voices": { "es-MX": { "voice": "204", "disclaimer": "Audio generado por IA." } },
        "audio_polish_level": "3",
        "player": { "title_intro": "Listen:" }
      }'
```
---

## Playlists

A playlist is a curated group of your published articles: pinned tracks in
order, optionally topped up by a tag rule ("fill" — any newly published
article carrying one of the playlist's tags is added, newest first), with a
player embed and its own RSS feed for podcast platforms. Studio owns the
playlist itself (it is where you build and manage one); this API is for
reading a playlist's contents and pinning or unpinning tracks as new audio
publishes — most useful right after `create_article` succeeds.

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/playlists` | List this account's playlists |
| `POST` | `/v1/playlists` | Create a playlist |
| `GET` | `/v1/playlists/{id}` | One playlist, with its resolved tracks and pins |
| `PATCH` | `/v1/playlists/{id}` | Change its rules |
| `DELETE` | `/v1/playlists/{id}` | Delete it |
| `POST` | `/v1/playlists/{id}/articles` | Pin an article |
| `PATCH` | `/v1/playlists/{id}/articles/{article}` | Move a pinned article |
| `DELETE` | `/v1/playlists/{id}/articles/{article}` | Unpin an article |

```bash
curl -X POST https://api.everlit.audio/v1/playlists/plist_a1b2c3/articles \
  -H "Authorization: Bearer $EVERLIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"article": "artl_9f3k2q9m7v4n6b1c5d8e0h"}'
# => 201
# {"object":"playlist_pin","playlist":"plist_a1b2c3","id":"plart_9k2m","article_id":"artl_9f3k...","position":4}
```

`article` (on both the pin and unpin routes) accepts either an `ajob_...`
`job_id` from `create_article` — resolved to the article it published,
so you never have to wait for a second lookup — or the `artl_...` article id
directly. A job with no published article yet answers `409 job_not_complete`.
Unpinning only removes a pin: an article the tag rule filled in is not a pin
and cannot be removed this way (edit the rule in Studio instead).

The playlist resource:

```json
{
  "object": "playlist",
  "id": "plist_a1b2c3",
  "name": "Morning Briefing",
  "description": "Top stories, read aloud",
  "tags": ["news"],
  "ignore_tags": [],
  "match": "any",
  "size": 25,
  "order": "latest",
  "within": "any",
  "within_start": null,
  "within_end": null,
  "fill": true,
  "publications": [],
  "embed_url": "https://everlit.audio/embeds/plist_a1b2c3",
  "embed_html": "<iframe src=\"https://everlit.audio/embeds/plist_a1b2c3\" ...></iframe>",
  "feed_url": "https://creator.everlit.audio/feeds/lists/plist_a1b2c3",
  "feed_distributed": false
}
```

`publications`, when non-empty, restricts the playlist to those publications'
articles and to keys scoped to (at least) one of them — an unrestricted
playlist (`[]`) is visible to every key on the account. `feed_url` is always
present — it is the RSS feed creator itself serves for the playlist, valid
whether or not you have submitted it anywhere; `feed_distributed` tells you
whether it has actually been submitted to podcast platforms (Apple, Spotify,
...) yet. `GET /v1/playlists/{id}` additionally returns `tracks` (the
resolved, ordered list of articles — pinned plus any the tag rule filled in,
each with `article_id`, `title`, `published_at`, `duration_seconds`,
`publication`, `player_url`) and `pins` (just the pins, with their `id` and
`position`, for the ones `remove_from_playlist` / `DELETE .../articles/{article}`
can remove).

### Changing the rules

`PATCH /v1/playlists/{id}` changes the playlist itself. Only the fields you
send change; omitting one (or sending `null`) leaves it alone. Send at least
one field.

| Field | Type | Meaning |
|---|---|---|
| `name` | string | Playlist name |
| `description` | string | Shown on the player and in the feed |
| `tags` | array of strings | The fill rule: articles carrying these tags join automatically |
| `match` | `"any"` \| `"all"` | Whether an article needs any of the tags or all of them |
| `ignore_tags` | array of strings | Exclude articles carrying any of these tags |
| `size` | integer 1–100 | How many tracks the playlist holds |
| `order` | `played` \| `viewed` \| `latest` \| `oldest` \| `random` | Which tracks survive the cap, and in what order |
| `within` | `any` \| `current_day` \| `current_week` \| `current_month` \| `last_day` \| `last_week` \| `last_month` \| `custom` | Only articles published in this window |
| `within_start`, `within_end` | string | Required when `within` is `"custom"` |
| `publications` | array of `pblc_` ids | Restrict the playlist to these publications |
| `fill` | boolean | `false`: pinned articles only, no tag rule |

```bash
curl -X PATCH https://api.everlit.audio/v1/playlists/plist_a1b2c3 \
  -H "Authorization: Bearer $EVERLIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["news", "politics"], "ignore_tags": ["sponsored"], "size": 15, "order": "latest"}'
```

An unknown field is a `400 unknown_parameter`; a publication your key cannot
see is a `404 publication_not_found`.

### Reordering and deleting

`PATCH /v1/playlists/{id}/articles/{article}` moves a pinned article. Send
exactly one of:

* `before` — the article (an `artl_...` id or an `ajob_...` `job_id`) this one
  should sit in front of, or `null` to move it to the end.
* `position` — a 1-based slot among the playlist's pins. A position past the
  last pin moves it to the end.

Sending both, or neither, is a `400`. Only pins can be moved — an article the
tag rule filled in is ordered by the rule, and moving one answers
`404 article_not_found`. The response is the pin in its new place:
`{"object":"playlist_pin","playlist":"plist_a1b2c3","id":"plart_9k2m","article_id":"artl_9f3k...","position":1}`.

`DELETE /v1/playlists/{id}` deletes the playlist itself and answers `204`: its
player and page come down, but the articles in it are untouched and keep their
own players. If the playlist had a [podcast feed](#podcast-feeds), the feed is
deactivated rather than destroyed — it stops publishing new episodes, and its
metadata is still there if you rebuild the show later.

---

## Podcast feeds

Every playlist is already served as an RSS feed at its `feed_url`. What turns
that URL into a podcast Apple, Spotify or Overcast will accept is the show's
metadata — title, description, owner email, category, cover art, language —
plus switching distribution on. That is what these routes do.

The flow: `PATCH .../feed` to fill the metadata → `POST .../feed/activate` →
submit the `feed_url` to the directories. Submission itself is done by a person
in Studio → Feeds (the directories require a human to accept their terms);
everything up to that point is here.

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/playlists/{id}/feed` | The feed, its metadata and its directory status |
| `PATCH` | `/v1/playlists/{id}/feed` | Set the podcast metadata |
| `POST` | `/v1/playlists/{id}/feed/activate` | Validate and start publishing |
| `POST` | `/v1/playlists/{id}/feed/deactivate` | Stop publishing (metadata kept) |
| `GET` | `/v1/playlists/{id}/feed/platforms` | Where the show stands on each directory |
| `POST` | `/v1/playlists/{id}/feed/platforms/{platform}/submitted` | Record a submission the user made |
| `POST` | `/v1/playlists/{id}/feed/platforms/{platform}/live` | Record the directory listing the show |
| `DELETE` | `/v1/playlists/{id}/feed/platforms/{platform}` | Clear one directory's record |
| `POST` | `/v1/playlists/{id}/feed/ping` | Ping Overcast to re-crawl |

```bash
curl -X PATCH https://api.everlit.audio/v1/playlists/plist_a1b2c3/feed \
  -H "Authorization: Bearer $EVERLIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "title": "The Morning Briefing",
        "description": "Top stories from the newsroom, read aloud every weekday.",
        "owner_email": "podcasts@example.com",
        "category": "News",
        "artwork_url": "https://cdn.example.com/briefing-3000.jpg",
        "language": "en",
        "keywords": ["news", "local"]
      }'

curl -X POST https://api.everlit.audio/v1/playlists/plist_a1b2c3/feed/activate \
  -H "Authorization: Bearer $EVERLIT_API_KEY"
```

**Required before a feed can go live:** `title`, `description`, `owner_email`,
`category`, `artwork_url` and `language`. Activation fills what it can from the
playlist itself (its name becomes the title, its description the description,
its tags the keywords, and the owner email defaults to a managed Everlit
address that receives the directories' verification codes), so in practice only
`artwork_url` usually has to be set by hand — square JPEG or PNG, 1400–3000px.

A feed never lacks a description: when none was written, one is derived as
"A show from *publication* featuring *title*" (the playlist's publication when
it is restricted to one, otherwise the account name).

`activate` answers `200` with `active: true` once the feed is live. When
metadata is still missing it is a `400 feed_incomplete` whose `error.message`
and `error.missing_fields` name the fields (usually just `artwork_url`) and
whose `error.feed` is the feed as it stands; set them with `PATCH .../feed`
and call activate again.

Every writable field, all optional: `title` (≤255), `description` (≤4000),
`subtitle` (≤255), `summary` (≤4000), `artwork_url` (http(s)), `website_url`
(http(s)), `author` (≤255), `owner_name` (≤255), `owner_email` (an email
address), `category`, `subcategory`, `language` (≤10, e.g. `"en"`), `explicit`
(boolean), `keywords` (array of strings), `copyright` (≤255), `type`
(`"episodic"` or `"serial"`), `complete` (boolean — the show is finished),
`locked` (boolean — block other platforms from importing the feed),
`funding_url` (http(s)) and `funding_text` (≤255). `null` leaves a field alone;
`""` clears it.

The feed resource:

```json
{
  "object": "feed",
  "playlist": "plist_a1b2c3",
  "feed_url": "https://creator.everlit.audio/feeds/lists/plist_a1b2c3",
  "active": true,
  "distribution_enabled": true,
  "validation_status": "valid",
  "validation_errors": [],
  "title": "The Morning Briefing",
  "description": "Top stories from the newsroom, read aloud every weekday.",
  "subtitle": null,
  "summary": null,
  "artwork_url": "https://cdn.example.com/briefing-3000.jpg",
  "website_url": null,
  "author": "Morning Briefing",
  "owner_name": "Example News",
  "owner_email": "podcasts@example.com",
  "category": "News",
  "subcategory": null,
  "language": "en",
  "explicit": false,
  "keywords": ["news", "local"],
  "copyright": null,
  "type": "episodic",
  "complete": false,
  "locked": true,
  "funding_url": null,
  "funding_text": null,
  "podcast_guid": "ead4c236-bf58-58c6-a2c6-a6b28d128cb6",
  "last_validated_at": "2026-09-11T09:00:00Z",
  "distributions": [
    { "platform": "apple", "status": "live", "platform_url": "https://podcasts.apple.com/...", "updated_at": "2026-09-11T09:05:00Z" }
  ]
}
```

`active` is the one to check: it means the feed both validates and has
distribution on. `distribution_enabled` is the switch alone, `validation_status`
(`pending` / `valid` / `incomplete`) the metadata alone. `podcast_guid` is the
show's permanent identity in the podcast namespace — it never changes once the
feed exists, which is what lets a show move hosts without losing its
subscribers. `distributions` lists the directories the show has been submitted
to and where each one stands.

A playlist with no feed set up yet still answers `GET .../feed`: `feed_url` is
there (it is always served), `active` is `false` and every metadata field is
`null`.

### Directories

A feed is only a URL until a directory carries it. Every directory except
Overcast requires a **person** to sign in and submit the feed — they all make
someone accept their terms — so these routes *record* what happened rather than
do it. The flow:

1. `POST .../feed/activate`, so there is a feed worth submitting.
2. `GET .../feed/platforms` and hand the user the `submission_url` for the
   directory they want.
3. The user submits the `feed_url` there.
4. `POST .../feed/platforms/{platform}/submitted` — with `notes` if there is
   anything worth remembering.
5. When the directory approves the show (hours to days), `POST
   .../feed/platforms/{platform}/live` with `platform_url`, the show's page on
   that directory.

The six platforms are `apple`, `spotify`, `pocketcasts`, `amazon`, `youtube`
and `overcast`. `status` is one of `not_started` (nothing recorded yet),
`pending`, `submitted`, `live`, `failed` or `removed`.

Two of them have no `submission_url`. **Apple** is submitted in
[Apple Podcasts Connect](https://podcastsconnect.apple.com) — the Studio wizard
walks a user through signing in there with an Apple ID and pasting the feed
URL. **Overcast** takes no submission at all: it discovers feeds on its own and
accepts a ping. `POST .../feed/ping` tells Overcast the feed changed so it
re-crawls now; every other directory polls on its own schedule, and there is
nothing to ping.

```bash
# The user submitted the feed at podcasters.spotify.com
curl -X POST https://api.everlit.audio/v1/playlists/plist_a1b2c3/feed/platforms/spotify/submitted \
  -H "Authorization: Bearer $EVERLIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"notes": "Submitted from the newsroom account"}'

# Spotify approved it
curl -X POST https://api.everlit.audio/v1/playlists/plist_a1b2c3/feed/platforms/spotify/live \
  -H "Authorization: Bearer $EVERLIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"platform_url": "https://open.spotify.com/show/4kL2..."}'
```

Both take an optional `platform_podcast_id` (the directory's own id for the
show); `submitted` also takes `notes` (≤1000 characters). `platform_url` is
**required** on `live` for every directory a person submits to — that is, all
but Overcast. Both need the feed to be active first, or they answer
`409 feed_not_active`; `submitted` on a platform already `live` is a
`409 already_live` (reset it first). An unknown platform is a
`404 platform_not_found`.

`DELETE .../feed/platforms/{platform}` forgets Everlit's record — the platform
reads `not_started` again — and `404 platform_not_started` when there was
nothing to forget. It withdraws nothing at the directory itself.

The `feed_platform` resource:

```json
{
  "object": "feed_platform",
  "playlist": "plist_a1b2c3",
  "platform": "spotify",
  "name": "Spotify",
  "status": "live",
  "submission_url": "https://podcasters.spotify.com/",
  "manual_submission": true,
  "supports_ping": false,
  "platform_url": "https://open.spotify.com/show/4kL2...",
  "platform_podcast_id": "4kL2...",
  "submitted_at": "2026-09-11T09:05:00Z",
  "live_at": "2026-09-13T14:20:00Z",
  "last_checked_at": "2026-09-13T14:20:00Z",
  "last_error": null,
  "notes": "Submitted from the newsroom account"
}
```

`GET .../feed/platforms` answers `{ "object": "list", "data": [...],
"has_more": false }` with one row per platform, in that order, whether or not
anything has been recorded for it. The `distributions` array on the feed itself
carries the same fields (minus `object` and `playlist`, plus `updated_at`) but
only for the platforms that have a record.

The ping answers:

```json
{
  "object": "feed_ping",
  "playlist": "plist_a1b2c3",
  "platform": "overcast",
  "pinged_at": "2026-09-13T14:20:00Z",
  "ok": true,
  "message": "Overcast will re-crawl the feed shortly."
}
```

`ok: false` with a `message` means Overcast refused or timed out; it is safe to
try again. Pinging a feed that is not active is a `409 feed_not_active`.

---

## Ingestion feeds

An ingestion feed is a standing instruction rather than a one-off call: point a
publication at an RSS or Atom feed and every item the feed publishes from then
on is narrated automatically, with that publication's
[defaults](#publication-settings) — voice, music, disclaimer, player. Set it up
once and the newsroom's own CMS feed becomes the input to the audio.

Everything about *how* an item is narrated comes from the publication, not the
feed. The feed itself only decides which items are picked up: how often it is
checked, how many a single check may take, and how far back to go.

**Each item narrated is an article and counts against your plan's monthly
article quota,** exactly as if you had called `POST /v1/articles` for it. A busy
feed with no `max_items` can spend a month's quota in one poll; `max_items` and
`oldest_date` are what keep the first poll from narrating the whole archive.

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/ingestion-feeds` | Feeds this key can see (optionally `?publication=pblc_...`) |
| `POST` | `/v1/ingestion-feeds` | Start ingesting a feed → `201` |
| `GET` | `/v1/ingestion-feeds/{id}` | One feed |
| `PATCH` | `/v1/ingestion-feeds/{id}` | Change the interval, caps, name, or pause/resume it |
| `DELETE` | `/v1/ingestion-feeds/{id}` | Stop ingesting and delete the feed → `204` |
| `POST` | `/v1/ingestion-feeds/{id}/poll` | Check the feed now |
| `GET` | `/v1/ingestion-feeds/{id}/items` | What the feed has seen and what became of it |

```bash
curl -X POST https://api.everlit.audio/v1/ingestion-feeds \
  -H "Authorization: Bearer $EVERLIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "publication": "pblc_a1b2c3",
        "url": "https://example.com/feed.xml",
        "name": "Front page",
        "poll_interval_minutes": 15,
        "max_items": 5,
        "oldest_date": "2026-09-01T00:00:00Z"
      }'
```

`publication` and `url` are required; everything else is optional.
`poll_interval_minutes` is an integer from 5 to 1440 (default `15`), `max_items`
an integer from 1 to 500 (`null` = no cap), `oldest_date` an ISO 8601 timestamp
below which items are skipped (`null` = the whole feed), `name` a label of up to
120 characters, and `status` either `"active"` (the default) or `"paused"`.
`poll_now` (default `true`) polls once immediately as well as on the interval,
so you see whether the feed parses without waiting a quarter of an hour.

A feed URL is ingested once across Everlit: a `url` already on a feed answers
`409 ingestion_feed_exists`, naming the existing feed's id when it is one of
yours. The url of an existing feed cannot be changed — a different address is a
different feed, so delete this one and create another.

Deleting a feed removes it and the record of the items it has seen. **Articles
already narrated from it are not deleted** — they are ordinary articles with
their own players from the moment they are published.

The feed resource:

```json
{
  "object": "ingestion_feed",
  "id": "rsif_9k2mq4x7dhz",
  "publication": "pblc_a1b2c3",
  "url": "https://example.com/feed.xml",
  "name": "Front page",
  "status": "active",
  "poll_interval_minutes": 15,
  "max_items": 5,
  "oldest_date": "2026-09-01T00:00:00Z",
  "last_polled_at": "2026-09-11T09:15:00Z",
  "last_build_date": "2026-09-11T09:12:00Z",
  "last_error": null,
  "items": { "total": 42, "completed": 40, "processing": 1, "error": 1 },
  "created_at": "2026-09-01T12:00:00Z",
  "updated_at": "2026-09-11T09:15:00Z"
}
```

`status` is `active` while the feed is being polled, `paused` while you have
stopped it, and `error` when the last poll failed — `last_error` says why, and
setting `status` back to `"active"` clears it. A paused feed is polled by
nobody: `POST .../poll` on one answers `409 ingestion_feed_paused`.

### Polling now

`POST /v1/ingestion-feeds/{id}/poll` queues an immediate check and answers at
once — the items are narrated in the background, so read
`GET .../items` afterwards to see what came of it:

```json
{ "object": "ingestion_poll", "feed": "rsif_9k2mq4x7dhz", "queued": true, "queued_at": "2026-09-11T09:20:00Z" }
```

Manual polls are throttled to one a minute per feed; inside that minute the call
is a `429 poll_too_soon` with a `Retry-After` header giving the seconds left.
The interval polls are unaffected.

### Items

`GET /v1/ingestion-feeds/{id}/items` lists what the feed has seen, newest
first. `?limit` is 1 to 100 (default 50), `?status` filters to
`pending` / `processing` / `completed` / `error`, and `?starting_after` takes an
item id to page after; `has_more` says whether another page exists.

```json
{
  "object": "ingestion_item",
  "id": "rsii_4t8bn1qwlvz",
  "feed": "rsif_9k2mq4x7dhz",
  "guid": "https://example.com/news/city-budget",
  "url": "https://example.com/news/city-budget",
  "title": "City approves budget",
  "status": "completed",
  "article": "artl_x7y8z9",
  "published_at": "2026-09-11T09:05:00Z",
  "last_error": null,
  "created_at": "2026-09-11T09:15:00Z",
  "updated_at": "2026-09-11T09:16:30Z"
}
```

`article` is the article the item became — an `artl_` id you can pass to
`GET /v1/articles/{id}`, `PATCH`, or `POST /v1/playlists/{id}/articles` — and is
`null` until the narration finishes. An item that failed carries `status:
"error"` and the reason in `last_error`; the feed keeps going.

---

## Pronunciations

The pronunciation library fixes how a name or word is spoken, for every
future narration — not just one article. A rule is either account-wide
(`publication` omitted or `null`, applied to every publication) or scoped to
one publication (its own rules apply on top of the account-wide ones). This
changes only narrations made **after** the rule is set; an article already
narrated keeps its original audio until you `regenerate: true` it.

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/pronunciations` | List this account's rules (optionally `?publication=pblc_...`) |
| `PUT` | `/v1/pronunciations` | Add or update a rule (upsert on word + scope) |
| `DELETE` | `/v1/pronunciations/{word}` | Remove a rule (optionally `?publication=pblc_...`) |

```bash
curl -X PUT https://api.everlit.audio/v1/pronunciations \
  -H "Authorization: Bearer $EVERLIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"word": "Nguyen", "say": "win"}'
# => 200
# {"object":"pronunciation","word":"Nguyen","say":"win","exact_match":false,"publication":null,"updated_at":"2026-09-09T00:00:00Z"}
```

`word` (up to 120 characters) is the text as it appears in your content;
`say` (up to 300 characters) is how it should sound — spell it phonetically,
the way you'd want it read aloud, not an IPA transcription. `exact_match`
(default `false`) matches the word only as a whole word rather than also
inside longer words containing it. A `PUT` with the same `word` and
`publication` updates the existing rule in place rather than creating a
second one; a `word` set both account-wide and for a publication keeps both —
the publication's rule is what that publication's narrations use.

---

## 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.
- **Concurrent conversions** are capped at the number of publications on
  your account: one running conversion per publication (`max_concurrent_articles`
  under `plan` in `GET /v1/account`; `null` means no cap). Over it,
  `POST /v1/articles` answers `429 concurrency_limit_exceeded` with
  `Retry-After`; wait for a running job to finish, then retry. Requests that
  converge onto an existing article never take a slot. This cap is a plan
  feature only: pay-as-you-go credit or a card adds extra lanes for speech
  (`POST /v1/speech`), never for article conversions.
- **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`.
  `PATCH /v1/publications/:id` is a write: it draws on the create bucket, not
  the read one.
- **Capacity**: a busy conversion queue does not refuse a request that is
  within your concurrency cap — it is accepted and simply waits its turn.
  Only when the queue is badly backed up do 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` |
| `get_publication` | `GET /v1/publications/{id}` |
| `update_publication` | `PATCH /v1/publications/{id}` |
| `create_article` | `POST /v1/articles` (accepts an `idempotency_key` argument) |
| `get_article` | `GET /v1/articles/{id}` (accepts `idempotency_key` instead of `id`) |
| `list_articles` | `GET /v1/articles` (accepts `ids` and `idempotency_key`) |
| `update_article` | `PATCH /v1/articles/{id}` |
| `delete_article` | `DELETE /v1/articles/{id}` |
| `list_voices` | `GET /v1/voices` |
| `list_mixables` | `GET /v1/mixables` |
| `update_playlist` | `PATCH /v1/playlists/{id}` |
| `delete_playlist` | `DELETE /v1/playlists/{id}` |
| `move_in_playlist` | `PATCH /v1/playlists/{id}/articles/{article}` |
| `get_feed` | `GET /v1/playlists/{id}/feed` |
| `update_feed` | `PATCH /v1/playlists/{id}/feed` |
| `activate_feed` | `POST /v1/playlists/{id}/feed/activate` |
| `deactivate_feed` | `POST /v1/playlists/{id}/feed/deactivate` |
| `list_feed_platforms` | `GET /v1/playlists/{id}/feed/platforms` |
| `mark_feed_submitted` | `POST /v1/playlists/{id}/feed/platforms/{platform}/submitted` |
| `mark_feed_live` | `POST /v1/playlists/{id}/feed/platforms/{platform}/live` |
| `reset_feed_platform` | `DELETE /v1/playlists/{id}/feed/platforms/{platform}` |
| `ping_feed` | `POST /v1/playlists/{id}/feed/ping` |
| `list_ingestion_feeds` | `GET /v1/ingestion-feeds` |
| `create_ingestion_feed` | `POST /v1/ingestion-feeds` |
| `get_ingestion_feed` | `GET /v1/ingestion-feeds/{id}` |
| `update_ingestion_feed` | `PATCH /v1/ingestion-feeds/{id}` |
| `delete_ingestion_feed` | `DELETE /v1/ingestion-feeds/{id}` |
| `poll_ingestion_feed` | `POST /v1/ingestion-feeds/{id}/poll` |
| `list_ingestion_items` | `GET /v1/ingestion-feeds/{id}/items` |
| `create_mixable` | `POST /v1/mixables` |
| `delete_mixable` | `DELETE /v1/mixables/{id}` |

**Endpoint:** `https://api.everlit.audio/v1/mcp` — Streamable HTTP transport,
stateless, JSON responses (see
[Sessions and updates](/docs/tts-api#sessions-and-updates)). Authenticate either by connecting
with OAuth (log in to Studio, no key to copy) or with the same
`Authorization: Bearer api_...` header the REST endpoints take — see
[Connect with OAuth](/docs/tts-api#mcp).

```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 an audio article from a url, or from title + text, in a publication; returns the queued job with job_id. Poll get_article until "succeeded", then use player_url.

| Argument | Type | Required | Description |
|---|---|---|---|
| `publication` | string | yes | The publication id (pblc_...) from list_publications. |
| `url` | string or null | no | Public http(s) URL to fetch and narrate; required unless title and text are given. |
| `title` | string or null | no | Article headline (with text, skips fetching the url). |
| `text` | string or null | no | Plain-text article body, up to 200,000 characters; same inline tags as create_speech. |
| `audio_url` | string or null | no | https URL of your own MP3/WAV to publish instead of narrating; title required. |
| `authors` | array or null | no | Author names, read aloud as the byline. |
| `summary` | string or null | no | Short description shown on the player. |
| `category` | string or null | no | Section/category name. |
| `tags` | array or null | no | Extra tags. |
| `art_url` | string or null | no | Cover image URL. |
| `published_at` | string or null | no | ISO 8601 publication time; defaults to now. |
| `voice` | string or integer or null | no | Narrator voice id from list_voices. |
| `guest_voice` | string or integer or null | no | Second voice, used when podcast_mode is on. |
| `language` | string or null | no | Language tag to narrate in (e.g. "en-US"), skipping detection. |
| `podcast_mode` | boolean or null | no | Two-voice conversational narration. |
| `music` | boolean or null | no | Mix the publication's intro/outro beds in; default true. |
| `intro_music` | string or null | no | Intro bed: a music bed id, "shuffle" for a random one, or "" for none. |
| `outro_music` | string or null | no | Outro bed: a music bed id, "shuffle" for a random one, or "" for none. |
| `intro_music_duration` | number or null | no | Seconds of intro bed before the narration. |
| `outro_music_duration` | number or null | no | Seconds of outro bed after the narration. |
| `intro_music_pad` | number or null | no | Seconds the intro bed overlaps the narration. |
| `outro_music_pad` | number or null | no | Seconds the outro bed overlaps the narration. |
| `audio_polish` | boolean or null | no | Rewrite the text for the ear before narrating. |
| `audio_polish_level` | string or integer or null | no | How far Audio Polish may rewrite: "0" (symbols only) to "3". Default "2". |
| `read_urls` | boolean or null | no | Read URLs aloud. |
| `read_alt_text` | boolean or null | no | Read image alt text aloud. |
| `read_title` | boolean or null | no | Read the title aloud. |
| `read_author` | boolean or null | no | Read the byline aloud. |
| `custom_byline` | string or null | no | Exact byline text to read instead of the generated one. |
| `disclaimer` | string or null | no | Spoken AI disclaimer. "" narrates none; null uses the publication default. |
| `disclaimer_voice` | string or integer or null | no | Voice that reads the spoken disclaimer. |
| `ui_disclaimer` | boolean or null | no | Show the AI disclosure line under the player. |
| `regenerate` | boolean or null | no | Re-narrate even if an article for this url exists, keeping its last settings unless overridden here. |
| `callback_url` | string or null | no | https URL to POST the finished article to. |
| `metadata` | object or null | no | Up to 8 string values, 512 bytes total. |
| `idempotency_key` | string or null | no | Any unique string; a resubmit returns the original article job. |
| `mix` | boolean or null | no | Alias of music. |
| `intro_mixable` | string or null | no | Alias of intro_music. |
| `outro_mixable` | string or null | no | Alias of outro_music. |
| `sonic_optimizer` | boolean or null | no | Alias of audio_polish. |
| `conversation_mode` | boolean or null | no | Alias of podcast_mode. |

### `get_article`

Fetch an article by its artl_ id, or by the job_id create_article returned while the id is still null. Returns its status, and player_url once succeeded.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | no | The article id (artl_...); any article in this Studio, whatever created it. |
| `job_id` | string | no | The conversion job id (ajob_...); use it while id is still null. |
| `idempotency_key` | string | no | Instead of id: the idempotency_key the article was created with. |

### `list_articles`

List this account's articles, newest first - API jobs plus everything in Studio (widget, WordPress, RSS, uploads). Page with starting_after.

| Argument | Type | Required | Description |
|---|---|---|---|
| `limit` | integer | no | Page size (default 20). |
| `starting_after` | string | no | Return articles older than this id (artl_... or a job_id). |
| `status` | enum: queued, processing, succeeded, failed, deleted | no | Filter by status. |
| `publication` | string | no | Filter by publication id. |
| `source` | enum: all, api, studio | no | all (default), api (jobs from this API) or studio (Studio only). |
| `q` | string | no | Match on id, job id, url, title, error code or metadata. |
| `ids` | array | no | Only these article or job ids, up to 50. |

### `update_article`

Change an article's title, summary, authors, tags, cover image, publish time or visibility without re-narrating. privacy "unlisted" hides it from playlists and feeds.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The article id (artl_...) or job_id (ajob_...). |
| `title` | string or null | no | Article headline. |
| `summary` | string or null | no | Short description shown on the player. |
| `authors` | array or null | no | Replaces the article's byline. |
| `tags` | array or null | no | Replaces the article's tags. |
| `privacy` | enum: public, unlisted,  | no | "unlisted" hides it from playlists, feeds and lists; the player keeps working. |
| `published_at` | string or null | no | ISO 8601 publish time; a future time schedules the article. |
| `art_url` | string or null | no | Cover image URL. |
| `metadata` | object or null | no | Up to 8 string values, 512 bytes total; API-created articles only. |

### `delete_article`

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

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The article id (artl_...) or a job_id (ajob_...). |

### `list_publications`

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

_No arguments._

### `get_publication`

One publication and the defaults every article in it inherits: voice and disclaimer per language, per-author voices, music beds, Audio Polish and the player.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The publication id (pblc_...) from list_publications. |

### `update_publication`

Change a publication's defaults; only the fields you send change, and voices / ui_disclaimers merge language by language. To change one article only, use create_article.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The publication id (pblc_...). |
| `language` | string or null | no | Language tag to narrate in, skipping detection; one of the voices keys. "" turns it off. |
| `music` | boolean or null | no | Play the beds; shorthand for turning intro_music and outro_music off or on. |
| `intro_music` | string or null | no | Intro bed: a track id, "shuffle" for a random one, or "" for none. |
| `outro_music` | string or null | no | Outro bed: a track id, "shuffle" for a random one, or "" for none. |
| `intro_music_duration` | number or null | no | Seconds of intro bed before the narration. |
| `outro_music_duration` | number or null | no | Seconds of outro bed after the narration. |
| `intro_music_pad` | number or null | no | Seconds the intro bed overlaps the narration. |
| `outro_music_pad` | number or null | no | Seconds the outro bed overlaps the narration. |
| `audio_polish` | boolean or null | no | Rewrite the text for the ear before narrating. |
| `audio_polish_level` | string or integer or null | no | How far Audio Polish may rewrite. One of 0, 1, 2, 3. |
| `podcast_mode` | boolean or null | no | Two-voice conversational narration. |
| `read_urls` | boolean or null | no | Read URLs aloud. |
| `read_alt_text` | boolean or null | no | Read image alt text aloud. |
| `read_title` | boolean or null | no | Read the title aloud. |
| `read_author` | boolean or null | no | Read the byline aloud. |
| `voices` | object or null | no | Per language tag (e.g. "en-US"): narrator, guest voice, spoken disclaimer. null removes a language. |
| `author_voices` | array or null | no | Voice per author name; beats the per-language voice. [] clears the list. |
| `ui_disclaimers` | object or null | no | Per language tag: the AI disclosure line under the player. "" shows none; null drops it. |
| `pauses` | object or null | no | Seconds of silence after each meta field: a number or a [min, max] pair. "" restores the default. |
| `player` | object or null | no | How the embedded player looks. |
| `studio` | object or null | no | Studio-only preferences; no effect on published audio. |
| `sharing` | object or null | no | The player share button, and which URL it shares. |
| `email_capture` | object or null | no | Asking listeners for an email address. |
| `extraction` | object or null | no | How the Auto Audio widget reads a page; only needed when extraction gets it wrong. |
| `widget` | object or null | no | How the widget behaves: which hosts it runs on, and when it narrates a page. |
| `paywall` | object or null | no | Paywall gating, and the call to action readers see. |

### `list_mixables`

Intro/outro music beds this account may use. Use the id as intro_music / outro_music on create_article or update_publication. Full-length songs (ad music) are not beds: they are left out unless you pass kind "song", and are refused as intro_music / outro_music.

| Argument | Type | Required | Description |
|---|---|---|---|
| `kind` | string | no | Filter by kind as reported on each row. Full-length songs (kind "song", ad music that cannot be an intro or outro) are listed only when asked for here. |
| `q` | string | no | Free-text match on name or tags. |
| `limit` | integer | no | Page size (default 100). |
| `starting_after` | string | no | Return beds with an id greater than this one. |

### `create_mixable`

Add a music bed of your own from an https url (25 MB max; mp3/wav/m4a/ogg/flac), usable at once as intro_music / outro_music. Kind "song" instead adds a full-length track for ads, which is never an intro or outro.

| Argument | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | Display name of the bed, up to 120 characters. |
| `url` | string | yes | https URL of the audio file to fetch (mp3, wav, m4a, ogg or flac; 25 MB max). |
| `kind` | enum: intro-outro, song, sfx, ambient | no | Track kind; "intro-outro" is the default. "song" is a full-length track, the kind ads use. |
| `tags` | object | no | Optional descriptive tags, each an array of up to 20 short strings. |
| `static_duration` | boolean | no | Play the track in full instead of trimming it to the configured seconds. |
| `publications` | array | no | Publication ids this bed is limited to; omit for the whole account. |

### `delete_mixable`

Delete one of your own music beds and clear it from any publication using it. Everlit library tracks cannot be deleted.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The music bed id from list_mixables. |

### `list_playlists`

Playlists in this account: name, tag rule, publications, player embed and RSS feed_url. Use the id with get_playlist / add_to_playlist.

_No arguments._

### `get_playlist`

One playlist with its pins and resolved tracks (the pins plus any filled in by its tag rule).

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The playlist id (plist_...). |

### `create_playlist`

Create a playlist. Pin articles with `articles`, and/or give `tags` so new articles carrying them fill it, newest first.

| Argument | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | Playlist name. |
| `description` | string or null | no | Shown on the player and in the feed. |
| `publications` | array or null | no | Restrict to these publication ids (pblc_...). |
| `tags` | array or null | no | Fill rule: articles carrying any of these tags. |
| `articles` | array or null | no | Articles to pin: article ids (artl_...) or job ids (ajob_...). |

### `add_to_playlist`

Pin a published article to a playlist by artl_ id, or by the job_id create_article returned. It must have finished publishing.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |
| `article` | string | yes | article id (artl_...) or job_id (ajob_...). |

### `remove_from_playlist`

Unpin an article from a playlist. Articles the tag rule filled in are not pins and cannot be removed this way.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |
| `article` | string | yes | article id (artl_...) or job_id (ajob_...). |

### `update_playlist`

Change a playlist's rules: `tags`/`match`/`ignore_tags` fill it, `within` limits it by publish date, `size` caps it, `order` sorts it, `fill` false leaves only pins.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The playlist id (plist_...). |
| `name` | string or null | no | Playlist name. |
| `description` | string or null | no | Shown on the player and in the feed. |
| `tags` | array or null | no | Fill rule: articles carrying these tags. |
| `match` | enum: any, all,  | no | Match any of the tags (default) or all of them. |
| `ignore_tags` | array or null | no | Exclude articles carrying any of these tags. |
| `size` | integer or null | no | How many tracks the playlist holds, 1-100. |
| `order` | enum: played, viewed, latest, oldest, random,  | no | Track order: most played, most viewed, newest, oldest, or random. |
| `within` | enum: any, current_day, current_week, current_month, last_day, last_week, last_month, custom,  | no | Only articles published in this window. |
| `within_start` | string or null | no | Start of the window when within is "custom". |
| `within_end` | string or null | no | End of the window when within is "custom". |
| `publications` | array or null | no | Restrict to these publication ids (pblc_...). |
| `fill` | boolean or null | no | false: pinned articles only, no tag rule. |

### `delete_playlist`

Delete a playlist: its player, page and podcast feed come down. The articles in it are NOT deleted.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The playlist id (plist_...). |

### `move_in_playlist`

Move a pinned article: give `before`, the article it should sit in front of (null moves it last), or `position`. Only pins can be moved.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |
| `article` | string | yes | The article to move: article id (artl_...) or job_id (ajob_...). |
| `before` | string or null | no | Put it in front of this article (artl_.../ajob_...); null moves it last. |
| `position` | integer or null | no | 1-based position among the pins. |

### `get_feed`

The podcast feed on a playlist: its feed_url, whether it is active, the metadata, what validation is missing, and the directories submitted to.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |

### `update_feed`

Set the podcast metadata on a playlist's feed; "" clears a field, null leaves it alone. activate_feed needs title, description, owner_email, category, artwork_url, language.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |
| `title` | string or null | no | Show title. |
| `description` | string or null | no | Show description, as directories display it. |
| `subtitle` | string or null | no | One-line subtitle. |
| `summary` | string or null | no | Longer summary. |
| `artwork_url` | string or null | no | https URL of the cover art (square, 1400-3000px). |
| `website_url` | string or null | no | The show's web page. |
| `author` | string or null | no | Author shown in directories. |
| `owner_name` | string or null | no | Feed owner name. |
| `owner_email` | string or null | no | Feed owner email — directories send verification here. |
| `category` | string or null | no | Apple podcast category, e.g. "News". |
| `subcategory` | string or null | no | Apple podcast subcategory. |
| `language` | string or null | no | Language code, e.g. "en". |
| `explicit` | boolean or null | no | Explicit content flag. |
| `keywords` | array or null | no | Keywords for discovery. |
| `copyright` | string or null | no | Copyright line. |
| `type` | enum: episodic, serial,  | no | episodic (newest first) or serial. |
| `complete` | boolean or null | no | true: the show is finished, no more episodes. |
| `locked` | boolean or null | no | true: block other platforms from importing the feed. |
| `funding_url` | string or null | no | Support/donation link. |
| `funding_text` | string or null | no | Label for the funding link. |

### `activate_feed`

Switch the playlist's podcast feed on, filling blanks from the playlist. 400 feed_incomplete names the fields still missing (usually artwork_url): set them with update_feed, then retry.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |

### `deactivate_feed`

Stop publishing the playlist's podcast feed; the metadata is kept, so activate_feed turns it back on.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |

### `list_feed_platforms`

Where the podcast feed stands on each directory: status, the submission_url to hand the user, and platform_url once live.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |

### `mark_feed_submitted`

Record that the show was submitted to a directory. Submits nothing itself: a person signs in at the submission_url from list_feed_platforms.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |
| `platform` | enum: apple, spotify, pocketcasts, amazon, youtube, overcast | yes | The directory. |
| `notes` | string or null | no | What the user reported, e.g. the account they submitted from. |
| `platform_url` | string or null | no | The show's page on the directory, if it is already known. |
| `platform_podcast_id` | string or null | no | The directory's own id for the show, if it gave one. |

### `mark_feed_live`

Record that a directory approved the show and it is now listed; platform_url is where listeners get sent.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |
| `platform` | enum: apple, spotify, pocketcasts, amazon, youtube, overcast | yes | The directory. |
| `platform_url` | string or null | no | The show's page on the directory. |
| `platform_podcast_id` | string or null | no | The directory's own id for the show. |

### `reset_feed_platform`

Forget what was recorded for one directory: it goes back to not_started, losing its notes and platform_url. Nothing is withdrawn at the directory.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |
| `platform` | enum: apple, spotify, pocketcasts, amazon, youtube, overcast | yes | The directory. |

### `ping_feed`

Tell Overcast the feed changed so it refreshes now; other directories poll on their own schedule.

| Argument | Type | Required | Description |
|---|---|---|---|
| `playlist` | string | yes | The playlist id (plist_...). |

### `list_ingestion_feeds`

The RSS/Atom feeds this account narrates automatically: the publication each feeds, its poll interval and status, last poll, and item counts.

| Argument | Type | Required | Description |
|---|---|---|---|
| `publication` | string or null | no | Only feeds on this publication (pblc_...). |

### `create_ingestion_feed`

Narrate every new item of an RSS/Atom feed into a publication, polled every poll_interval_minutes. Each narration uses an article quota.

| Argument | Type | Required | Description |
|---|---|---|---|
| `publication` | string | yes | The publication the items are published into (pblc_...). |
| `url` | string | yes | The RSS or Atom feed address (http(s)). |
| `name` | string or null | no | A label for the feed (<= 120 characters). |
| `poll_interval_minutes` | integer or null | no | How often to check, 5 to 1440 minutes. Default 15. |
| `max_items` | integer or null | no | Most items to narrate in one poll, 1 to 500. Null = no cap. |
| `oldest_date` | string or null | no | ISO 8601: skip items published before this. Null = the whole feed. |
| `status` | enum: active, paused | no | Start polling ("active", the default) or hold it ("paused"). |
| `poll_now` | boolean or null | no | Poll immediately as well as on the interval. Default true. |

### `get_ingestion_feed`

One ingestion feed: its publication, poll interval, status, last poll, last error and item counts.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The ingestion feed id (rsif_...). |

### `update_ingestion_feed`

Change a feed's name, poll interval, item cap or date floor, or pause and resume it. The url cannot be changed - delete the feed and create another.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The ingestion feed id (rsif_...). |
| `name` | string or null | no | A label for the feed (<= 120 characters). |
| `poll_interval_minutes` | integer or null | no | How often to check, 5 to 1440 minutes. |
| `max_items` | integer or null | no | Most items to narrate in one poll, 1 to 500; null removes the cap. |
| `oldest_date` | string or null | no | ISO 8601 floor on item publish dates; null removes it. |
| `status` | enum: active, paused | no | Resume ("active") or hold ("paused") the feed. |

### `delete_ingestion_feed`

Stop ingesting a feed and delete it with the record of items it has seen. Articles already narrated are NOT deleted.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The ingestion feed id (rsif_...). |

### `poll_ingestion_feed`

Check a feed now rather than waiting for its interval, one manual poll a minute. New items are narrated in the background; read list_ingestion_items.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The ingestion feed id (rsif_...). |

### `list_ingestion_items`

What a feed has seen, newest first: each item's title, link, status and the article it became, or the error that stopped it.

| Argument | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The ingestion feed id (rsif_...). |
| `status` | enum: pending, processing, completed, error | no | Only items in this state. |
| `limit` | integer or null | no | 1 to 100; default 50. |
| `starting_after` | string or null | no | Item id (rsii_...) to page after. |

### `list_pronunciations`

The account's pronunciation library: how each word is read aloud, account-wide (publication null) or for one publication.

| Argument | Type | Required | Description |
|---|---|---|---|
| `publication` | string | no | Only rules that apply to this publication (its own plus account-wide). |

### `set_pronunciation`

Add or update how a word is read aloud in every future narration; spell `say` the way it sounds. Scope it with `publication`, else account-wide.

| Argument | Type | Required | Description |
|---|---|---|---|
| `word` | string | yes | The text as written (up to 120 characters). |
| `say` | string | yes | How to read it, spelled phonetically (up to 300 characters). |
| `exact_match` | boolean or null | no | Match the word only as a whole word (default false: also inside longer words). |
| `publication` | string or null | no | Publication id (pblc_...) to scope the rule to; omit for account-wide. |

### `delete_pronunciation`

Remove a pronunciation rule (the exact word and scope it was set with).

| Argument | Type | Required | Description |
|---|---|---|---|
| `word` | string | yes | The word as it was set. |
| `publication` | string or null | no | The publication the rule was scoped to; omit for an account-wide rule. |

<!-- 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 |
| 400 | `invalid_playlist` | Studio rejected the playlist (see `message`) |
| 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 |
| 403 | `terms_not_accepted` | The publisher has not accepted the current Everlit Terms of Service. `next_step` is `accept_terms`; `resolution_url` is the acceptance page. One member accepts once; access resumes immediately. |
| 404 | `publication_not_found` | Not on your account (or outside this key's publication scope) |
| 404 | `playlist_not_found` / `pronunciation_not_found` | No playlist/pronunciation at that id or word |
| 404 | `article_not_found` / `voice_not_found` / `mixable_not_found` / `unknown_endpoint` | Nothing at that address |
| 409 | `job_not_complete` | A `job_id` named as `article` has not published yet |
| 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`) |
| 429 | `concurrency_limit_exceeded` | As many conversions running as your account has publications; wait for one to finish (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.9.0 — 2026-10-02**
- `read_title`: leave the title out of the narration. Per article on `POST
  /v1/articles` / `create_article`, or as a publication default on `PATCH
  /v1/publications/{id}` / `update_publication`, which Studio and the
  WordPress plugin also follow. Absent means `true`, as before.

**1.8.0 — 2026-09-11**
- Inline markup in article `text`: `POST /v1/articles` and `create_article`
  accept the same `[everlit-pause:N]`, `[everlit-mixable:N]`,
  `[everlit-mixable-background:N]` and `[everlit-subvoice-open:N]`…
  `[everlit-subvoice-close]` tags as the TTS API. Every id is checked against
  the account, tags are never counted in `characters`, the article resource
  reports them as `markup`, and an `audio_url` article's `text` is left
  untouched. See [Inline markup](#inline-markup).
- Podcast directories: `GET /v1/playlists/{id}/feed/platforms` lists where the
  show stands on Apple, Spotify, Pocket Casts, Amazon, YouTube and Overcast
  (with the `submission_url` to hand a person); `POST .../platforms/{platform}/submitted`
  and `.../live` record a submission and a listing; `DELETE .../platforms/{platform}`
  resets one; `POST .../feed/ping` asks Overcast to re-crawl. The feed
  resource's `distributions` rows carry the same fields. MCP:
  `list_feed_platforms`, `mark_feed_submitted`, `mark_feed_live`,
  `reset_feed_platform`, `ping_feed`. See [Directories](#directories).
- Ingestion feeds: `GET/POST /v1/ingestion-feeds`, `GET/PATCH/DELETE
  /v1/ingestion-feeds/{id}`, `POST .../poll` and `GET .../items` — point a
  publication at an RSS or Atom feed and every item it publishes is narrated
  with that publication's defaults. `poll_interval_minutes` (5–1440),
  `max_items` and `oldest_date` decide what is picked up; each narration
  counts against the plan's article quota. MCP: `list_ingestion_feeds`,
  `create_ingestion_feed`, `get_ingestion_feed`, `update_ingestion_feed`,
  `delete_ingestion_feed`, `poll_ingestion_feed`, `list_ingestion_items`.
  See [Ingestion feeds](#ingestion-feeds).

**1.7.0 — 2026-09-11**
- `PATCH /v1/articles/{id}` and the `update_article` MCP tool: change an
  article's title, summary, tags, cover image, publish time or privacy without
  re-narrating it — for every article in the account's Studio. `privacy:
  "unlisted"` keeps the player working but hides the article from playlists,
  feeds and lists; a future `published_at` schedules it (unlisted until then)
  unless the same request sets `privacy`. See [Update article](#update-article).
- `audio_url` on `POST /v1/articles` / `create_article`: publish your own
  recording instead of narrating one. The file is fetched, transcribed for
  read-along, mixed with the publication's intro/outro beds (or the ones you
  send) and published as an ordinary audio article. `title` is required, `text`
  optional (the transcript fills it in); narration parameters are rejected.
  Such an article reports `characters` and `voice` as `null`, narrates no spoken
  AI disclaimer and carries the AI disclosure as **false** in its ID3 tags.
- Playlists: `PATCH /v1/playlists/{id}` (name, description, `tags`, `match`,
  `ignore_tags`, `size`, `order`, `within` + `within_start`/`within_end`,
  `publications`, `fill`), `DELETE /v1/playlists/{id}`, and
  `PATCH /v1/playlists/{id}/articles/{article}` to reorder a pin (`before` or
  `position`). The playlist resource reports `ignore_tags`, `within`,
  `within_start`, `within_end` and `fill`. MCP: `update_playlist`,
  `delete_playlist`, `move_in_playlist`.
- Podcast feeds: `GET`/`PATCH /v1/playlists/{id}/feed` and
  `POST /v1/playlists/{id}/feed/activate` / `.../deactivate` — set the show's
  metadata, activate it (`400 feed_incomplete` names anything still missing;
  a description is always derived when none was written), submit `feed_url`
  to the directories. MCP: `get_feed`,
  `update_feed`, `activate_feed`, `deactivate_feed`. See
  [Podcast feeds](#podcast-feeds).
- Publication settings now cover everything the Studio form does: an
  `extraction` block (`content_selector`, `title_selector`, `date_selector`,
  `exclude_selectors`, `tag_field_map`, `site_name_tag_prefix`,
  `tag_namespaces`), a `widget` block (`allowed_origins`, `click_to_create`,
  `click_to_create_message`, `generate_on_first_view`, `rss_only`), a `paywall`
  block, and `player.theme` (fourteen named colours and the title/body web
  fonts, merged key by key; `""` restores a derived default).
- Your own music beds: `POST /v1/mixables` / `create_mixable` uploads a bed,
  sound effect or ambient track from an `https` URL (25 MB max) with optional
  `tags`, `static_duration` and `publications`; `GET` and
  `DELETE /v1/mixables/{id}` / `delete_mixable` read or remove one you uploaded
  (deleting also clears it from any publication still pointing at it).
  Everlit's library stays read-only (`403 not_owned`).

**1.6.0 — 2026-09-09**
- `GET /v1/articles/{id}`, `DELETE /v1/articles/{id}` and the `get_article` /
  `delete_article` MCP tools now take an article id (`artl_...`) as well as a
  job id, so every article in the account's Studio — the Auto Audio widget,
  WordPress, RSS, Studio uploads — is readable and deletable here. See
  [Any article, any origin](#any-article-any-origin).
- `GET /v1/articles` lists those alongside API jobs, newest first, and takes
  `source=all|api|studio` (`all` is the default). `starting_after` and `ids`
  accept both id forms.
- The article resource gained `source`, `published_at`, `privacy`, `tags`,
  `summary`, `authors` and `cover`.
- `id` is now the article id (`artl_...`) on every article; the conversion job
  moved to `job_id` (`ajob_...`). `article_id` was removed. Jobs created
  before this release were renamed to `ajob_...` ids.
- `get_article` takes a `job_id` argument as an alternative to `id`.
- MCP server `version` **1.9.0 → 1.12.0**.

**1.5.0 — 2026-09-09**
- New `GET`/`POST /v1/playlists`, `GET /v1/playlists/{id}`, `POST`/`DELETE
  /v1/playlists/{id}/articles`, and the `list_playlists`, `get_playlist`,
  `create_playlist`, `add_to_playlist`, `remove_from_playlist` MCP tools. See
  [Playlists](#playlists).
- New `GET`/`PUT /v1/pronunciations`, `DELETE /v1/pronunciations/{word}`, and
  the `list_pronunciations`, `set_pronunciation`, `delete_pronunciation` MCP
  tools: fix how a name is read for every future narration, account-wide or
  per publication. See [Pronunciations](#pronunciations).
- A `get_article` (REST or MCP) that finds the job still pending no longer
  counts against the read rate limit when it waited `poll_after_seconds` (30)
  since the previous poll of that job, so honest polling can never lock a key
  out; a faster re-poll still counts.
- MCP server `version` **1.8.0 → 1.9.0**.

**1.4.0 — 2026-09-08**
- New `GET /v1/mixables` / `list_mixables`: the intro/outro music beds
  `intro_music` / `outro_music` may reference. See [Music beds](#music-beds).
- `get_article` accepts `idempotency_key` instead of `id`; `list_articles`
  and `GET /v1/articles` accept `ids` (up to 50) and `idempotency_key`, for
  crash recovery and checking on many jobs at once.
- Errors carry `next_step` and `resolution_url`; see the
  [TTS API changelog](/docs/tts-api#changelog) for the full list of codes —
  the same envelope is shared across both APIs.
- New `GET /v1/account` / `get_account` on the shared MCP server: publisher,
  key, entitlements, billing, limits and plan — call it first on a new
  connection. See the [TTS API guide](/docs/tts-api#account).
- MCP resources (`everlit://docs/tts-api`, `everlit://docs/articles-api`,
  `everlit://languages`) and prompts (`narrate_text`, `publish_from_url`).
- MCP server `version` **1.7.0 → 1.8.0**.

**1.3.2 — 2026-09-05**
- Music beds can shuffle: `intro_music` / `outro_music` accept `"shuffle"` on `POST /v1/articles` and `PATCH /v1/publications/{id}` (and through the `create_article` / `update_publication` MCP tools) — a random track from the publication's intro/outro music, picked fresh for every narration and never stored on the article.
- A publication reports each bed in the same three forms it takes — an id, `"shuffle"`, `""` — so a `defaults` object always `PATCH`es back unchanged. An intro with no track set now reads as `"shuffle"` rather than `null` (that is what it has always played), an outro with none reads as `""`, and `music` is `false` only when neither bed plays.

**1.3.1 — 2026-09-04**
- `regenerate: true` now really does keep the settings the article was last narrated with: the stored per-article disclaimer flags are carried into the re-narration, and an explicit `disclaimer` (a string, or `""`) in the same request still wins.
- MCP: every optional argument of `create_article` and `update_publication` accepts `null` (it means "no opinion", exactly as over HTTP), voice ids and `audio_polish_level` accept an integer as well as a string, and the legacy aliases `mix`, `intro_mixable`, `outro_mixable`, `sonic_optimizer` and `conversation_mode` are in the `create_article` schema again.
- A language configured with a blank spoken disclaimer or a blank `ui_disclaimers` entry now narrates and shows none for that language, instead of falling through to another language's line.

**1.3.0 — 2026-09-04**
- `GET /v1/publications/{id}` and `PATCH /v1/publications/{id}`: read and change everything a publication hands its articles — voices, disclaimers and the AI disclosure line per language, per-author voices, music, Audio Polish, pauses, the player, sharing and email capture. With the `get_publication` / `update_publication` MCP tools.
- One vocabulary whether a setting is a publication default or a per-article override: `music`, `intro_music`, `outro_music`, `audio_polish`, `audio_polish_level`, `podcast_mode`, the intro/outro duration and pad fields, `language` and `ui_disclaimer` on `POST /v1/articles`. `mix`, `intro_mixable`, `outro_mixable`, `sonic_optimizer` and `conversation_mode` keep working as aliases.
- `language` on an article, and a publication default language, skip detection.
- `intro_music: ""` / `outro_music: ""` play no bed at all — previously an unset intro let one be chosen and an unset outro fell back to the publication's.
- `GET /v1/publications` now reports each publication's real `default_voice` and `language_voices`.

**1.2.0 — 2026-09-04**
- `disclaimer: ""` on `POST /v1/articles` silences the spoken AI disclaimer for that article; omitting `disclaimer` or sending `null` uses the publication default and leaves anything stored on the article alone. The strings `"false"`, `"null"`, `"none"` and `"off"` are rejected with `400 invalid_parameter` rather than narrated.

**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://everlit.openstatus.dev
