Skip to main content

Add-ons API

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

Features

  • Full CRUD: list, create, correct and delete add-ons of any publication 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

HeaderExampleDescription
X-User-Tokenyour-api-tokenAPI token generated in your dashboard. Header-only for security.

All requests must be performed over HTTPS.

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 publication. Operations on publications your store lists but does not own (distributed content) are rejected with 403 Forbidden.


Endpoints

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

MethodPathDescription
GET/api/v3/content/{content}/add-onsList add-ons
POST/api/v3/content/{content}/add-onsCreate an add-on
POST/api/v3/content/{content}/add-ons/bulkBulk create
PUT/api/v3/content/{content}/add-ons/{id}Update
DELETE/api/v3/content/{content}/add-ons/{id}Delete

{content} accepts the publication's internal ID by default, or any of its identifiers (ISBN, external ID) with ?id_type=external. Same contract as the Content API.

There is no endpoint for fetching a single add-on by ID. A publication carries a few dozen add-ons at most, so List add-ons 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 publications. 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 publication 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:

PublicationMessage
PDF still convertingThe publication page count is not available yet; retry once the conversion finished.
EPUB the platform has not numberedThis 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.
{
"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.

"anchor": {
"type": "page",
"page": 14,
"locator": { "href": "OEBPS/chapter03.xhtml", "type": "application/xhtml+xml" }
}
TypeWhat it isPDFEPUB reflowableEPUB fixed layoutWritten by
pageA whole pagethis API
pointA coordinate on a pagethis API
textA passage of textthe reader
imageA figuren/athe 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

FieldTypeRequiredDescription
anchor.typestringYespage or point
anchor.pageintegerYesThe page the reader shows for this position, min 1, within the publication's page count
anchor.pointobjectFor point{ "x": 0-100, "y": 0-100 } percentages from the top-left of the page
anchor.locator.hrefstringWith anchor.locatorThe file of the publication's reading order that holds the page, matched verbatim by the reader
anchor.locator.typestringNoMedia type of that file, e.g. application/xhtml+xml
anchor.locator.locationsobjectNo{ "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 publication'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:

{
"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 sendStored anchorStored 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 sentstart_page: 12, location_in_start_page: null
anchor: { "type": "point", "page": 12, "point": … }as sentstart_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:

{
"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

{
"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"
}
FieldTypeDescription
idstringAdd-on identifier
content_idstringInternal ID of the publication the add-on belongs to
titlestringTitle, max 255 characters
pretitlestring | nullKicker shown above the title, max 255 characters
authorstring | nullAuthor name, max 255 characters
by_linestring | nullDeck or by-line, max 1,000 characters
typestringarticle, audio, video, image or link. Defaults to article
bodystringHTML body, sanitized on every write. Max 60,000 characters of text content (markup excluded)
body_formatstring | nullRead-only. html once a body went through the sanitizer; null on records written before it
image_urlstring | nullURL of the stored add-on image. Set through image_from_url on create/update
image_captionstring | nullImage caption, max 2,000 characters
start_pageintegerPage where the add-on marker appears (1-based)
end_pageintegerLast page the add-on spans. Defaults to start_page
location_in_start_pageobject | nullMarker position inside the start page: { "x": 0-100, "y": 0-100 } percentages from top-left
anchorobject | nullWhere the reader places the piece. See Anchoring. null on records written before anchors existed
statusstringdraft or published. See lifecycle
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last update timestamp

Review Lifecycle

StatusReader visibility
draftInvisible to readers in every surface: web reader, apps, offline downloads
publishedVisible 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: the draft filter protects readers, not integrators.
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 publication 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.

SurvivesRemoved
p, br, strong/b, em/i, u, s/del, ul/ol/li, a, imgEvent handlers (onclick…), inline style, class, script, table, code, blockquote, unknown attributes
iframe and video from allowed embed domainsEmbeds from other domains, javascript: and other unsafe URLs

Headings (h1h6) 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:
{
"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 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.

X

Graph View