#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:
error— the gate's stable code, from the SCF §9 vocabularymessage— the same human sentence every other door giveserrors[]— the full structured list: code, message, and location
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:
- No endpoint is removed or renamed.
- No field is removed from a response.
- No previously-accepted request becomes invalid.
- No error code is renamed. New codes may be added — a client must tolerate a
code it does not recognise, and the
errors[]array is the stable place to read them from. - New optional request fields may be added. They will always have a default that preserves current behaviour.
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.