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

# Create an Add-on

Create a single add-on on a product you own. For batches without images, use the [bulk endpoint](./add-ons-v3-bulk.md) (up to 100 per request).

## Endpoint

```
POST /api/v3/content/{content}/add-ons
```

`{content}` is the product's internal ID, or any of its identifiers with `id_type=external`. The product must be a **PDF or an EPUB** and must be numbered (a PDF by its finished conversion, an EPUB by its page markers), because every position is validated against the product's page count. Either condition unmet returns `422` with the reason under `errors.content`; the [overview](./add-ons-v3-overview.md#endpoints) lists the exact messages.

---

## Request Body

| Field                    | Type    | Required | Description                                                                                                                                |
| ------------------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `title`                  | string  | Yes      | Max 255 characters                                                                                                                         |
| `body`                   | string  | Yes      | HTML body, [sanitized on save](./add-ons-v3-overview.md#rich-text-bodies). Max 60,000 characters of text content (HTML markup not counted) |
| `start_page`             | integer | Yes\*    | Page where the marker appears, min 1. \*Required unless the payload carries an `anchor`                                                    |
| `anchor`                 | object  | No†      | Where the reader places the piece. See [Anchoring](./add-ons-v3-overview.md#anchoring). †Required on EPUB products                         |
| `end_page`               | integer | No       | Last page the piece spans, ≥ the page it starts on. Defaults to that page                                                                  |
| `pretitle`               | string  | No       | Max 255 characters                                                                                                                         |
| `author`                 | string  | No       | Max 255 characters                                                                                                                         |
| `by_line`                | string  | No       | Deck or by-line, max 1,000 characters                                                                                                      |
| `type`                   | string  | No       | `article` (default), `audio`, `video`, `image` or `link`                                                                                   |
| `status`                 | string  | No       | `draft` (default) or `published`                                                                                                           |
| `location_in_start_page` | object  | No       | `{ "x": 0-100, "y": 0-100 }`, marker position as percentages from top-left. Not accepted together with `anchor`                            |
| `image_from_url`         | string  | No       | URL of an image to download and attach. See [image handling](#image-handling)                                                              |
| `image_caption`          | string  | No       | Max 2,000 characters                                                                                                                       |

:::warning source and body_format are not accepted
Both are stamped by the system: `source` from the creation channel (always `integration` for this API, and never returned) and `body_format` by the sanitizer (always `html` here, returned read-only). Sending either returns `422`.
:::

### Marker position format

`location_in_start_page` must be an object with numeric `x` and `y` percentages between 0 and 100, measured from the top-left corner of the start page:

```json
{ "x": 12.5, "y": 40 }
```

Malformed positions (strings, missing coordinates, out-of-range or non-numeric values) are rejected with `422` and a message spelling out the expected format.

### Anchoring the piece

A PDF takes either form. An EPUB takes the anchor only: the legacy pair cannot say where a piece sits in a book whose pages are not fixed.

```json
{ "anchor": { "type": "page", "page": 14 } }
```

On a **fixed-layout EPUB**, place the piece on a coordinate and name the file of the page it sits on:

```json
{
  "anchor": {
    "type": "point",
    "page": 15,
    "locator": {
      "href": "OEBPS/page015.xhtml",
      "type": "application/xhtml+xml"
    },
    "point": { "x": 30, "y": 20 }
  }
}
```

A `point` anchor needs a page whose geometry does not reflow, so on a reflowable EPUB it is refused with `A point anchor needs a page with fixed geometry; a reflowable EPUB takes a page anchor.` under `errors.anchor.type`. A page beyond the product is refused under `errors.anchor.page`, the same way `start_page` is.

### Image handling

Images are referenced **by URL only**. There is no direct file upload. On create, the system:

1. Downloads the image from `image_from_url` (15-second timeout).
2. Validates it is a real **JPEG, PNG, GIF or WebP** under **10 MB**. Anything else is rejected with `422` and the add-on is not created.
3. Optimizes it (fitted within 1024×1024, quality 85) and stores it on the product's asset storage.

The stored copy's URL is returned as `image_url`. `image_url` itself is a response field: sending it in a request returns `422`.

---

## Request Example

On a PDF, with a marker on the page:

```bash
curl -X POST "https://yourstore.publica.la/api/v3/content/123/add-ons" \
  -H "X-User-Token: your-api-token" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Interview with the author",
    "body": "<p>Full interview…</p>",
    "start_page": 12,
    "location_in_start_page": { "x": 25.5, "y": 40 },
    "image_from_url": "https://example.com/photo.jpg",
    "status": "published"
  }'
```

On an EPUB, anchored to the page the reader shows as 14:

```bash
curl -X POST "https://yourstore.publica.la/api/v3/content/456/add-ons" \
  -H "X-User-Token: your-api-token" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Context for chapter three",
    "body": "<p>Full piece…</p>",
    "anchor": { "type": "page", "page": 14 },
    "status": "published"
  }'
```

## Response

`201 Created` with the [add-on object](./add-ons-v3-overview.md#the-add-on-object) under `data`, plus a `sanitization` object listing what the [body sanitizer](./add-ons-v3-overview.md#rich-text-bodies) removed (empty when nothing was).

```json
{
  "data": {
    "id": "1044",
    "content_id": "123",
    "title": "Interview with the author",
    "body": "<p>Full interview…</p>",
    "body_format": "html",
    "status": "published",
    "start_page": 12,
    "end_page": 12,
    "location_in_start_page": { "x": 25.5, "y": 40 },
    "anchor": { "type": "point", "page": 12, "point": { "x": 25.5, "y": 40 } },
    "image_url": "https://assets.publica.la/yourstore/…/articles/article_1755100000000.jpg",
    "created_at": "2026-08-13T14:05:00.000000Z",
    "updated_at": "2026-08-13T14:05:00.000000Z"
  },
  "sanitization": {
    "discarded": []
  }
}
```

Compare `data.body` with what you sent when `sanitization.discarded` is not empty: that is the HTML readers will see.

## Errors

| Status | Condition                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Missing or invalid `X-User-Token`                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `403`  | Add-ons feature not enabled, non-administrator token, or the product is not owned by your store                                                                                                                                                                                                                                                                                                                                                                                               |
| `404`  | Product not found                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `422`  | Validation failure: missing required field, no position at all, both forms of position at once, an incomplete or out-of-range anchor, a `point` anchor on a reflowable EPUB, an EPUB create without an anchor, malformed marker position, `source`, `body_format` or `image_url` provided, invalid `status`, a body that sanitizes to nothing, image URL not downloadable or not a supported image, page bounds beyond the product, or a product that takes no add-ons or is not numbered yet |
| `429`  | Rate limit exceeded (20 requests per minute for create and update, since each may fetch a remote image)                                                                                                                                                                                                                                                                                                                                                                                       |
