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

# Add-ons API Overview

Manage the full lifecycle of your products' add-ons: interactive pieces (articles, audio, video, images, links) anchored to a position in a product and shown to readers on top of the content.

## Features

- **Full CRUD**: list, create, correct and delete add-ons of any product you own
- **PDF and EPUB**: place a piece on a page of either format, or on a coordinate of a page whose layout is fixed
- **Bulk creation** of up to 100 add-ons in a single request
- **Review lifecycle**: create as `draft` (invisible to readers) and publish when ready
- **Cursor-based pagination**, filtering, sorting and sparse fieldsets
- **Sanitized rich text**: every body written through the API is reduced to a fixed HTML subset, and the response lists what was removed

---

## Authentication

| Header           | Example          | Description                                                          |
| ---------------- | ---------------- | -------------------------------------------------------------------- |
| **X-User-Token** | `your-api-token` | API token generated in your dashboard. **Header-only for security.** |

All requests **must** be performed over HTTPS.

```bash
curl -X GET "https://yourstore.publica.la/api/v3/content/123/add-ons" \
  -H "X-User-Token: your-api-token" \
  -H "Accept: application/json"
```

---

## Requirements

### Feature availability

The **Add-ons API** feature gates this API only: requests from stores without it are rejected with `403 Forbidden` and the message `The Add-ons API is not enabled for this store.`, which distinguishes it from the other `403` causes below. Contact support to enable it.

Managing add-ons manually from the dashboard editor never requires this feature; every store can create add-ons by hand.

### Administrator token

The token must belong to an **administrator** of the store. Tokens of regular users are rejected with `403 Forbidden`.

### Content ownership

Add-ons belong to the **owner of the product**. Operations on products your store lists but does not own (distributed content) are rejected with `403 Forbidden`.

---

## Endpoints

Add-ons are nested under the product they belong to:

| Method   | Path                                     | Description                                |
| -------- | ---------------------------------------- | ------------------------------------------ |
| `GET`    | `/api/v3/content/{content}/add-ons`      | [List add-ons](./add-ons-v3-list.md)       |
| `POST`   | `/api/v3/content/{content}/add-ons`      | [Create an add-on](./add-ons-v3-create.md) |
| `POST`   | `/api/v3/content/{content}/add-ons/bulk` | [Bulk create](./add-ons-v3-bulk.md)        |
| `PUT`    | `/api/v3/content/{content}/add-ons/{id}` | [Update](./add-ons-v3-update-delete.md)    |
| `DELETE` | `/api/v3/content/{content}/add-ons/{id}` | [Delete](./add-ons-v3-update-delete.md)    |

`{content}` accepts the product's internal ID by default, or any of its identifiers (ISBN, external ID) with `?id_type=external`. Same contract as the [Content API](../content/content-v3-get.md).

There is no endpoint for fetching a single add-on by ID. A product carries a few dozen add-ons at most, so [List add-ons](./add-ons-v3-list.md) returns the whole set in one page and is the way to read one back after creating it. A `GET` on `/add-ons/{id}` answers `405`, so do not build a retry loop around it.

Add-ons display on **PDF and EPUB products**. Audiobooks and any other format are refused on create and bulk with `Add-ons are only supported on PDF and EPUB publications.`, while list, update and delete stay available for existing records on any format.

The product also has to be **numbered**, because every position names a page and pages are validated against the page count. A PDF gets that count when its conversion finishes; an EPUB gets it from the page markers the platform injects, which is the same numbering the reader shows. Create, bulk and any update that moves a position answer `422` with the reason under `errors.content` until it exists:

| Product                            | Message                                                                                 |
| ---------------------------------- | --------------------------------------------------------------------------------------- |
| PDF still converting               | `The publication page count is not available yet; retry once the conversion finished.`  |
| EPUB the platform has not numbered | `This EPUB has no page numbering yet, so an add-on cannot be anchored to a page in it.` |
| Anything else (audiobook…)         | `Add-ons are only supported on PDF and EPUB publications.`                              |

```json
{
  "message": "The publication page count is not available yet; retry once the conversion finished.",
  "errors": {
    "content": [
      "The publication page count is not available yet; retry once the conversion finished."
    ]
  }
}
```

An update that leaves the position alone is unaffected, so the rest of an existing add-on stays correctable meanwhile.

---

---

## Anchoring

An add-on's position travels as an `anchor`: the kind of position it is, the page it names, and whatever that kind needs.

```json
"anchor": {
  "type": "page",
  "page": 14,
  "locator": { "href": "OEBPS/chapter03.xhtml", "type": "application/xhtml+xml" }
}
```

| Type    | What it is             | PDF | EPUB reflowable | EPUB fixed layout | Written by |
| ------- | ---------------------- | --- | --------------- | ----------------- | ---------- |
| `page`  | A whole page           | Yes | Yes             | Yes               | this API   |
| `point` | A coordinate on a page | Yes | No              | Yes               | this API   |
| `text`  | A passage of text      | Yes | Yes             | Yes               | the reader |
| `image` | A figure               | n/a | Yes             | Yes               | the reader |

`text` and `image` anchors are placed from the reader, which is the only place that knows the selections and figures of the book it is showing. Sending either returns `422`.

### Fields

| Field                      | Type    | Required              | Description                                                                                 |
| -------------------------- | ------- | --------------------- | ------------------------------------------------------------------------------------------- |
| `anchor.type`              | string  | Yes                   | `page` or `point`                                                                           |
| `anchor.page`              | integer | Yes                   | The page the reader shows for this position, min 1, within the product's page count         |
| `anchor.point`             | object  | For `point`           | `{ "x": 0-100, "y": 0-100 }` percentages from the top-left of the page                      |
| `anchor.locator.href`      | string  | With `anchor.locator` | The file of the product's reading order that holds the page, matched verbatim by the reader |
| `anchor.locator.type`      | string  | No                    | Media type of that file, e.g. `application/xhtml+xml`                                       |
| `anchor.locator.locations` | object  | No                    | `{ "position": integer, "progression": number }`, passed through untouched                  |

### Which page number

The page an anchor names is **the number the reader displays**, not an offset into the file:

- **PDF**: the page of the document, as today.
- **EPUB**: the printed page when the book carries its publisher's page markers, and the position the platform numbered it with when it does not. It is the same number a reader sees in the page indicator, and the same one the product's page count reports.

### The locator, and when it is needed

A **reflowable EPUB** is addressed by page number alone: the locator is optional there and can be left out.

A **fixed-layout EPUB** (one pre-paginated file per page) is addressed by that file, so a piece placed on one should carry `anchor.locator.href`. Without it the piece still lists under its page, but the reader has no file to draw its marker on.

The platform never invents a locator: what you send is stored and handed to the reader verbatim.

### One position per payload

`anchor` and the legacy pair (`start_page` plus `location_in_start_page`) are two ways of saying the same thing, so a request carries **one or the other**, never both:

```json
{
  "message": "anchor replaces start_page and location_in_start_page; send the position one way or the other.",
  "errors": {
    "anchor": [
      "anchor replaces start_page and location_in_start_page; send the position one way or the other."
    ]
  }
}
```

Whichever you send, the stored add-on carries both, because the platform derives one from the other:

| You send                                              | Stored anchor                                      | Stored legacy pair                                 |
| ----------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- |
| `start_page: 12`                                      | `{ "type": "page", "page": 12 }`                   | unchanged                                          |
| `start_page: 12` + `location_in_start_page: {x, y}`   | `{ "type": "point", "page": 12, "point": {x, y} }` | unchanged                                          |
| `anchor: { "type": "page", "page": 12 }`              | as sent                                            | `start_page: 12`, `location_in_start_page: null`   |
| `anchor: { "type": "point", "page": 12, "point": … }` | as sent                                            | `start_page: 12`, `location_in_start_page: {x, y}` |

So a PDF integration written before anchors existed keeps working untouched, and an EPUB, where the legacy pair cannot say enough, is addressed by anchor. On an EPUB, a create without one is refused:

```json
{
  "message": "An add-on on an EPUB publication must carry an anchor.",
  "errors": {
    "anchor": ["An add-on on an EPUB publication must carry an anchor."]
  }
}
```

Records created before anchors existed carry `"anchor": null`; readers derive their position from the legacy pair, and an update that touches the position writes the anchor in.

## The Add-on Object

```json
{
  "id": "1044",
  "content_id": "9911",
  "title": "Interview with the author",
  "pretitle": "Exclusive",
  "author": "Jane Roe",
  "by_line": "Culture desk",
  "type": "article",
  "body": "<p>Full HTML body of the add-on…</p>",
  "body_format": "html",
  "image_url": "https://assets.publica.la/yourstore/…/articles/article_1755100000000.png",
  "image_caption": "The author at home",
  "start_page": 12,
  "end_page": 13,
  "location_in_start_page": { "x": 25.5, "y": 40 },
  "anchor": {
    "type": "point",
    "page": 12,
    "point": { "x": 25.5, "y": 40 }
  },
  "status": "draft",
  "created_at": "2026-08-13T14:05:00.000000Z",
  "updated_at": "2026-08-13T14:05:00.000000Z"
}
```

| Field                    | Type           | Description                                                                                                      |
| ------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------- |
| `id`                     | string         | Add-on identifier                                                                                                |
| `content_id`             | string         | Internal ID of the product the add-on belongs to                                                                 |
| `title`                  | string         | Title, max 255 characters                                                                                        |
| `pretitle`               | string \| null | Kicker shown above the title, max 255 characters                                                                 |
| `author`                 | string \| null | Author name, max 255 characters                                                                                  |
| `by_line`                | string \| null | Deck or by-line, max 1,000 characters                                                                            |
| `type`                   | string         | `article`, `audio`, `video`, `image` or `link`. Defaults to `article`                                            |
| `body`                   | string         | HTML body, sanitized on every write. Max 60,000 characters of text content (markup excluded)                     |
| `body_format`            | string \| null | Read-only. `html` once a body went through the sanitizer; `null` on records written before it                    |
| `image_url`              | string \| null | URL of the stored add-on image. Set through `image_from_url` on create/update                                    |
| `image_caption`          | string \| null | Image caption, max 2,000 characters                                                                              |
| `start_page`             | integer        | Page where the add-on marker appears (1-based)                                                                   |
| `end_page`               | integer        | Last page the add-on spans. Defaults to `start_page`                                                             |
| `location_in_start_page` | object \| null | Marker position inside the start page: `{ "x": 0-100, "y": 0-100 }` percentages from top-left                    |
| `anchor`                 | object \| null | Where the reader places the piece. See [Anchoring](#anchoring). `null` on records written before anchors existed |
| `status`                 | string         | `draft` or `published`. See [lifecycle](#review-lifecycle)                                                       |
| `created_at`             | string         | ISO 8601 creation timestamp                                                                                      |
| `updated_at`             | string         | ISO 8601 last update timestamp                                                                                   |

---

## Review Lifecycle

| Status      | Reader visibility                                                              |
| ----------- | ------------------------------------------------------------------------------ |
| `draft`     | **Invisible to readers** in every surface: web reader, apps, offline downloads |
| `published` | Visible to readers                                                             |

- API-created add-ons default to `draft`. Pass `"status": "published"` to publish immediately.
- Publish a draft by updating it with `"status": "published"` (and back to `draft` to unpublish).
- The owner always sees drafts when [listing add-ons](./add-ons-v3-list.md): the draft filter protects readers, not integrators.

:::warning Unpublishing is not retroactive on offline downloads
Drafting or deleting an add-on applies to new reader sessions and new downloads. Offline packages already on a device keep their snapshot until the device downloads the product again.
:::

## Rich Text Bodies

Every `body` written through this API (create, bulk and update) is **sanitized before it is stored**. The HTML you send is reduced to the subset the reader renders, so what comes back on `GET` can differ from what went in.

| Survives                                                                      | Removed                                                                                                           |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `p`, `br`, `strong`/`b`, `em`/`i`, `u`, `s`/`del`, `ul`/`ol`/`li`, `a`, `img` | Event handlers (`onclick`…), inline `style`, `class`, `script`, `table`, `code`, `blockquote`, unknown attributes |
| `iframe` and `video` from allowed embed domains                               | Embeds from other domains, `javascript:` and other unsafe URLs                                                    |

Headings (`h1`–`h6`) are **flattened to paragraphs**: their text survives, the hierarchy does not.

Two fields report the outcome:

- `body_format` on every add-on object is `html` once a body has been through the sanitizer, and `null` on records written before this rule existed. It is read-only: sending it returns `422`.
- Create and update answer with a `sanitization` object next to `data`, listing every removal. It is additive and safe to ignore:

```json
{
  "data": {
    "id": "1044",
    "body": "<p>Interview</p><p>Full text…</p>",
    "body_format": "html",
    "…": "…"
  },
  "sanitization": {
    "discarded": [
      { "node": "p", "reason": "unsafe_attribute", "detail": "onclick" },
      { "node": "script", "reason": "tag_not_allowed", "detail": null }
    ]
  }
}
```

`reason` is one of `tag_not_allowed`, `unsafe_attribute`, `unsafe_url` or `embed_domain_not_allowed`. [Bulk](./add-ons-v3-bulk.md) carries the same list per item as `discarded`.

A body that sanitizes to **nothing** (for example a lone `<script>`) is rejected with `422` under `errors.body`, together with the `sanitization.discarded` list explaining why; nothing is written.

## Provenance

Every add-on records where it came from: the dashboard editor, an integration (this API or the legacy `create-article` endpoint) or automatic generation. The system stamps that provenance from the creation channel and keeps it internal: it is not part of the API contract, it does not travel in the add-on object, and sending `source` in any request returns `422`.
