Skip to content

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/mint builds a content state (and a ready-to-share viewer URL) from one of your own Presentation identifiers.
  • /content-state/resolve decodes 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 xywh region 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 an encodeURIComponent'd JSON, padding stripped) to place in an iiif-content parameter.
  • 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-content URL 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. Requires Authorization: Bearer <write_token>; the body must parse as a content state and is size-bounded. Returns 201 with a Location header and {"uri": "…"} naming the dereferenceable URL.
  • GET /content-state/s/{id} — the stored state as application/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