StoryLark
← All guides

StoryLark Content API v1

Status: normative. Deliverable D2 of the content standards plan — the load-bearing one.

This is the contract a publisher's system integrates against: a CMS, a release pipeline, an export job, a script. Content must conform to the StoryLark Content Format; this document covers how it arrives and what StoryLark promises about the door it arrives through.

Scope. This is the standard — the versioning promise, the auth model, the ownership rules, the error contract. The endpoint-by-endpoint reference lives with the code, in docs/content-api.md, so it stays current with the routes.


1. Why this is a separate surface

Everything under /api/admin is the portal's own backend. It moves when the portal moves, its shapes are whatever the screens need this week, and it authenticates with a browser session. That is the right posture for a UI's backend and the wrong one for something a publisher pins a release pipeline against for years.

So the push contract lives at its own prefix, with the version in the path:

/api/content/v1

A URL is what an integrator hard-codes. A major version that exists only inside a JSON field is a version nobody can route on, cache on, or migrate off gracefully. It is in the body as well, but the path is the one that matters.

The portal's routes and this API share one implementation underneath, so they cannot drift. That is the same discipline the theme import uses — one implementation, two callers — extended to a third.


2. The surface

POST /api/content/v1/books Batch push
PUT /api/content/v1/books/:bookId Create or replace one book
PUT /api/content/v1/books/:bookId/chapters/:chapterId Create or replace one chapter
DELETE /api/content/v1/books/:bookId/chapters/:chapterId Remove a chapter
DELETE /api/content/v1/books/:bookId Remove a book
POST /api/content/v1/import A zip of the markdown-folder layout

The import door exists for onboarding day, as a file. It accepts either a multipart upload — what a browser form sends — or a raw zip body, what curl --data-binary sends. It unpacks into ordinary book pushes and takes the identical path everything else takes.

2.1 Batch semantics

A batch is best-effort by default: good books land, bad ones are reported with their reasons, and one malformed entry does not discard the rest. A caller wanting all-or-nothing asks for it.

This is the same choice repo sync makes, for the same reason: a real catalogue always has something in flight, and a door that refuses everything because one entry is mid-edit is a door nobody can use.


3. Authentication

Two doors, deliberately:

Credential For
X-Admin-Key header A headless process — a pipeline, a CMS plugin, a cron job
An admin session An operator exercising the contract from a browser

The header exists because the primary caller cannot hold a cookie. The session exists so nobody has to mint a key just to try something once.

Scoped content tokens narrow this further: a token that may write content and nothing else, issued and revoked from the portal, so a CMS integration never holds a credential that can administer the deployment. A compromised CMS plugin should cost content, not the whole deployment.


4. Ownership — who owns the edit button

Content pushed here is read-only in the portal by default. Their system owns the content, so their system owns the edit button; a portal edit that the next push silently reverts is worse than no edit button at all.

The rule points both ways. This API refuses to write a book owned by a pull connector, because the next sync would silently revert the push. Both refusals are the same 409 with a message naming where the edit belongs.

And across sources: the first writer owns a book id. A later arrival claiming the same id from a different source is rejected with book_owned_elsewhere, naming the current owner. The same source writing again is an ordinary update.

Silent overwrite and last-write-wins were both rejected. The first lets two systems fight forever with nobody told; the second makes a library's contents depend on cron timing. Both hide a configuration error a human needs to see.


5. Errors

Every content rejection is a 422 carrying:

These are the same codes and the same messages the portal shows inline and a repo sync writes into its report. A transport may choose how to render an error; it may never rename one or invent one. There is a test that pushes identical malformed input through every door and asserts the results match.

5.1 A change made in v1's development

The umbrella invalid_markdown code was retired in favour of the gate's specific codes. Messages did not change and errors[] is additive, so a client reading prose is unaffected; one matching on the code string is. Recorded here because it happened before v1 was published, and after publication a change of this kind requires a major version (§6).


6. The compatibility promise

Within v1:

A change that breaks any of the above is v2, served alongside v1 rather than in place of it.

6.1 Outbound webhooks are deferred — stated, not omitted

StoryLark does not call back into a publisher's system — there is no "narration finished" notification in v1.

This is stated rather than left silent because silence would imply otherwise, and an integrator who assumes a callback exists builds a pipeline that quietly never completes. It is deferred, not rejected: adding it later is purely additive and therefore compatible with §6. Until then, poll.


7. Limits

Limit Value
Chapter source 2 MB (SCF §8)
Import archive Enforced and reported with the actual size and the ceiling
Ids 64 characters (SCF §3.3)

An oversized import is refused with a message naming both numbers and saying what to do — split the catalogue, since each import is independent.


8. The obligation this document creates

StoryLark deliberately does not ship a CMS. That decision only holds if this API is genuinely good, which means the ingestion story has to be the product.

The check on that is not internal review — the portal is a first-party caller and will always find its own API pleasant. It is a reference integration with an existing open-source CMS: a plugin that pushes to this contract, published as a worked example. Small, and the only test that answers "does your thing work with a CMS?" by demonstration rather than assertion.

If that integration turns out to be painful to write, this API is not finished, and that is the signal to fix it before anything else is built on top.


Found a gap? StoryLark is open source — improve these docs on GitHub.