Content State API¶
iiiris speaks the IIIF Content State API 1.0: the portable encoding of a specific view — a whole Manifest, a single Canvas, or a region of a Canvas — as a shareable "open the viewer exactly here" payload.
This surface is stateless and on by default, part of the zero-config
path. It requires the Presentation API (also on by default), since a content
state resolves to Presentation resources. Disable it with
content_state.enabled: false.
What it gives you¶
- The bundled viewers read
iiif-content. A link of the form/view/?iiif-content=<state>opens Mirador or Universal Viewer at the encoded target. The bare form needs no path identifier — the state carries its own target and its containing manifest. /content-state/mintbuilds a content state (and a ready-to-share viewer URL) from one of your own Presentation identifiers./content-state/resolvedecodes and inspects a state — a debugging aid.
Why
/content-state/and not/iiif/content-state/? The Content State spec is almost entirely client-side and defines no server endpoints. iiiris's mint/resolve helpers are conveniences, so they sit outside the/iiif/namespace — nothing here should be mistaken for a standard IIIF server API.
Opening a view — /view¶
When Content State is enabled, /view reads the iiif-content query
parameter:
| Request | Opens |
|---|---|
GET /view/{id} |
The whole manifest for {id} (unchanged) |
GET /view/?iiif-content=<state> |
The view the state encodes (canonical form) |
GET /view/{id}?iiif-content=<state> |
The state (it wins over {id} if they differ) |
?viewer=uv selects Universal Viewer (default is Mirador, per
presentation.default_viewer). Mirador is pointed at the state's target
canvas; Universal Viewer reads the state natively (canvas and region).
Region zoom. Universal Viewer honours an
xywhregion from the state. Mirador opens the correct canvas; initial zoom-to-region is best-effort and may not be exact.
Decoding is parse-only: iiiris extracts the target identifiers from the state and hands them to the viewer, which loads them in the browser. iiiris never fetches the target itself — a content state routinely points at an external manifest, and server-side fetching would be a request-forgery (SSRF) vector.
Minting a state — GET /content-state/mint¶
Build a shareable state from one of your Presentation identifiers.
| Parameter | Meaning |
|---|---|
target (required) |
A local Presentation identifier (e.g. library/manuscript). Not an absolute URL — mint reflects your own resources only. |
canvas |
1-based index of a canvas within the derived manifest. Omit for a whole-manifest state. |
xywh |
A region x,y,w,h of that canvas (requires canvas). |
v |
Presentation major version, 3 (default) or 2. |
Response (application/json):
{
"contentState": { "@context": "…/presentation/3/context.json",
"id": "https://host/iiif/presentation/3/library/manuscript/canvas/2#xywh=800,900,1000,1000",
"type": "Canvas",
"partOf": [{ "id": "https://host/iiif/presentation/3/library/manuscript", "type": "Manifest" }] },
"encoded": "JTdCJTIyQGNvbnRleHQ…",
"viewerUrl": "https://host/view/?iiif-content=JTdCJTIyQGNvbnRleHQ…"
}
contentState— the content state itself (a "target body", the spec's lightest interoperable form).encoded— the content-state-encoded value (base64url of anencodeURIComponent'd JSON, padding stripped) to place in aniiif-contentparameter.viewerUrl— the shareable link that opens your bundled viewer at this view.
Example:
# A region of the second canvas
curl 'http://localhost:8080/content-state/mint?target=library/manuscript&canvas=2&xywh=800,900,1000,1000'
Long states. A very large state can push an
iiif-contentURL past proxy length limits (~8 KB). For big/embedded states, prefer POSTing the raw state to a viewer, or (in a later release) hosting it and sharing the short URL. The v1 vocabulary is Manifest / Canvas / region; time points and multi-target comparison views are not minted yet.
Resolving a state — /content-state/resolve¶
Decode and inspect a state without opening a viewer.
# GET: the value as it would ride on a URL (content-state-encoded)
curl 'http://localhost:8080/content-state/resolve?iiif-content=JTdCJTIy…'
# POST: the raw JSON state (NOT encoded, per the spec's POST rule)
curl -X POST -H 'Content-Type: application/json' \
--data '{"id":"https://host/…/canvas/2","type":"Canvas","partOf":[{"id":"https://host/…","type":"Manifest"}]}' \
http://localhost:8080/content-state/resolve
Response reports the detected form, the normalised target, its containing manifest, and any warn-only structural problems:
{
"form": "target-body",
"target": { "id": "https://host/…/canvas/2", "type": "Canvas",
"region": "800,900,1000,1000", "manifestId": "https://host/…" },
"problems": [],
"contentState": { … }
}
Resolution is decode-only — like /view, it never dereferences the target.
Untrusted input (a decoded parameter or a POST body) is size-bounded.
Configuration¶
presentation:
enabled: true # required: Content State resolves to Presentation resources
content_state:
enabled: true # default-on; false unmounts /content-state/ and stops /view reading iiif-content
When content_state.enabled is false, the /content-state/ routes are not
mounted and /view ignores iiif-content entirely — behaviour is exactly as
it was before Content State shipped.
Bookmark store (Form-B hosting)¶
Off by default. When enabled, iiiris hosts content states at stable,
dereferenceable URIs, so a state can be shared as a short Form-B link
(iiif-content=https://host/content-state/s/…) instead of a long encoded blob.
content_state:
enabled: true
store:
enabled: true
dir: /var/lib/iiiris/content-state # durable, never evicted (required)
write_token: "a-long-random-secret" # POST requires: Authorization: Bearer <token>
POST /content-state— store a content state. RequiresAuthorization: Bearer <write_token>; the body must parse as a content state and is size-bounded. Returns 201 with aLocationheader and{"uri": "…"}naming the dereferenceable URL.GET /content-state/s/{id}— the stored state asapplication/ld+json(presentation-3 profile), with CORS + ETag + conditional GET. Public (reads are not gated).
# Store a state, get back its URI
curl -X POST -H "Authorization: Bearer $TOKEN" \
--data '{"id":"https://host/…/canvas/2","type":"Canvas","partOf":[{"id":"https://host/…","type":"Manifest"}]}' \
https://host/content-state
# -> 201, {"uri":"https://host/content-state/s/mfrggzdfmztwq2lk..."}
Write access. Writes are gated by write_token. If the store is enabled
but no token is set, writes are refused — an enabled store is never an open
relay. Reads stay public so any viewer can dereference a shared link.
Durability & scale. The store is a single-node, non-evicting filesystem
store: it mints permanent URIs and never removes an entry to reclaim space.
Multi-replica backends (Redis/S3) are not yet offered — run one writer, or put
a shared filesystem behind dir. Back up dir like any durable data.
Scheme correctness behind a proxy¶
Minted URLs (the manifest, canvas, and viewer URLs) are built from the request
scheme/host. Behind a TLS-terminating proxy, the proxy must send
X-Forwarded-Proto: https (and X-Forwarded-Host) or the URLs come out
http:// and a browser blocks them as mixed content — the same rule as the
Presentation API. See presentation.md.
Not yet shipped¶
- Multi-replica store backends (Redis/S3) — the store is single-node filesystem for now.
- Time selectors and multi-target comparison in mint.
See also¶
specs/content-state.md— the Content State contract (invariants).