Within the broader IIIF image delivery and manifest generation stage, the Image API is the pixel-serving foundation everything else stands on. A IIIF Presentation manifest tells a viewer what an object is and how its canvases are ordered; the Image API service tells that same viewer how to fetch any region, at any size, at any rotation, in any supported format from a single high-resolution master. Get the manifest right but the image service wrong and the object opens as a broken thumbnail. This guide specifies how to stand up a spec-compliant IIIF Image API 3.0 service: the URL grammar every request must satisfy, the info.json document that advertises the service’s capabilities, the tiling parameters that make deep zoom responsive, and the compliance level that tells clients exactly which features they may request.

The Image API solves a narrow, unforgiving problem. A museum holds a 400-megapixel TIFF of a manuscript folio; a researcher wants the top-left seal at native resolution while a classroom projector wants the whole page downsampled to fit 1080 pixels. Shipping the master to both is wasteful and slow. The Image API instead exposes one canonical identifier and lets each client compose a URL that names the exact region, size, rotation, and quality it needs, while info.json guarantees in advance that the request will be honoured. The service either serves a pre-generated tile, derives one on the fly, or — when rights gate the asset — returns a degraded placeholder instead.

Workflow Context

A IIIF Image API service sits between the persisted collection record and the viewer. Upstream, ingestion and taxonomy have produced a validated object with a stable identifier and a rights assertion; the Presentation manifest references this image service by URI in each canvas body. Downstream, a viewer such as Mirador or Universal Viewer reads info.json, discovers the tile grid and maximum dimensions, and issues a cascade of image requests as the user pans and zooms. The service’s job is to answer every one of those requests deterministically, within the guarantees info.json advertised, and to enforce the rights policy the record carries.

Two design pressures shape the configuration. The first is conformance: a client trusts info.json, so if the document advertises a maxWidth or a set of tiles the service cannot actually honour, the viewer will request URLs that 404 or time out. The second is performance: naively deriving every region from the master on each request melts the origin under a deep-zoom session that fires dozens of tile requests per pan. The configuration decisions here — tile size, scale factors, compliance level, and the choice between static pre-tiling and dynamic derivation — are what let a single origin survive a reading-room full of zoom.

Prerequisites

Before configuring the service, confirm the following are in place:

  • A tile source or image server. Either an on-the-fly server (IIPImage, Cantaloupe) that reads pyramidal TIFF or JPEG 2000 masters, or a static pre-tiling pipeline that writes derivatives to object storage behind a CDN. The static route is covered in serving deep-zoom IIIF tiles with pyvips.
  • pyvips (2.2+) bound to a recent libvips, for reading master dimensions and generating pyramidal derivatives without loading the whole raster into memory. libvips is the reference engine for this because it streams tiles rather than decoding the full image.
  • Pyramidal masters. Store archival images as tiled, multi-resolution TIFF or JP2 so a region request reads only the tiles it needs. A flat baseline JPEG forces a full decode for every crop.
  • A base URI scheme. Decide the identifier prefix — for example https://iiif.example.org/iiif/3/{identifier} — and keep it stable, because it is embedded in every manifest and cached by every client. Percent-encode identifiers that contain reserved characters.
  • Python 3.9+ for the PEP 604 | unions and match statements used to parse request paths below.
  • Standards references. The normative grammar is the IIIF Image API 3.0 specification; rights values are drawn from RightsStatements.org and Creative Commons URIs, exactly as the manifest layer asserts them.

Schema and Spec Reference

An Image API request is a structured URL, not an opaque endpoint. The path after the identifier is four ordered parameters plus a format extension:

text
{scheme}://{server}/{prefix}/{identifier}/{region}/{size}/{rotation}/{quality}.{format}

Each segment has a fixed grammar. The table below is the subset a museum delivery service must parse and validate on every request.

Segment Example values Meaning
region full, square, 125,15,120,140, pct:41,7,40,70 Full image, a centred square, absolute x,y,w,h pixels, or a percentage rectangle
size max, ^max, 600,, ,400, pct:50, !200,200 Largest allowed, upscaled max, fixed width, fixed height, percentage, or best-fit within a box
rotation 0, 90, !0, 22.5 Clockwise degrees; a leading ! mirrors before rotating
quality default, color, gray, bitonal Colour treatment of the returned bytes
format jpg, png, tif, webp Serialization; jpg is the universal default for delivery

The service advertises what it can actually do through info.json, requested at {identifier}/info.json. A client fetches this first and never requests a feature the document does not promise.

Property Type Role
@context string Must be http://iiif.io/api/image/3/context.json
id string The service base URI, without a trailing slash
type string Fixed value ImageService3
protocol string Fixed value http://iiif.io/api/image
profile string Compliance level: level0, level1, or level2
width / height integer Pixel dimensions of the full image
maxWidth / maxHeight / maxArea integer Upper bounds the server will serve, capping expensive derivations
sizes array Pre-derived {width, height} pairs a client may prefer for thumbnails
tiles array {width, height, scaleFactors} describing the tile grid for deep zoom
extraQualities / extraFormats / extraFeatures array Capabilities beyond the declared compliance level
rights string A rights statement URI, mirrored from the record and manifest

Compliance levels are a contract about which parameter values a client may use without checking extraFeatures. Level 0 serves only pre-generated derivatives — the exact sizes and tiles listed, nothing arbitrary. Level 1 adds arbitrary region requests and size by width or height. Level 2 adds arbitrary size (including !w,h best-fit and upscaling), rotation by any multiple of 90, and the color/gray/bitonal qualities. A static tile pyramid is naturally level 0; a dynamic server like Cantaloupe reaches level 2.

Step-by-Step Implementation

1. Construct info.json from the master’s dimensions

Read the master’s real width and height, then compute the tile grid and pre-derived sizes so the advertised document matches what the service can serve. Never hand-write dimensions — they must come from the pixels.

python
from __future__ import annotations

import pyvips


def build_info_json(
    identifier_uri: str,
    master_path: str,
    tile_size: int = 512,
    profile: str = "level1",
) -> dict[str, object]:
    """Build a IIIF Image API 3.0 info.json for one master image."""
    image = pyvips.Image.new_from_file(master_path, access="sequential")
    width, height = image.width, image.height

    scale_factors = _scale_factors(width, height, tile_size)
    return {
        "@context": "http://iiif.io/api/image/3/context.json",
        "id": identifier_uri,          # no trailing slash
        "type": "ImageService3",
        "protocol": "http://iiif.io/api/image",
        "profile": profile,
        "width": width,
        "height": height,
        "maxWidth": 5000,              # cap expensive on-the-fly derivations
        "sizes": _preferred_sizes(width, height),
        "tiles": [{"width": tile_size, "height": tile_size,
                   "scaleFactors": scale_factors}],
        "extraQualities": ["color", "gray", "bitonal"],
        "extraFormats": ["png", "webp"],
        "preferredFormats": ["jpg"],
    }


def _scale_factors(width: int, height: int, tile_size: int) -> list[int]:
    """Powers of two until the whole image fits inside one tile."""
    factors, factor = [1], 1
    while (width / factor) > tile_size or (height / factor) > tile_size:
        factor *= 2
        factors.append(factor)
    return factors


def _preferred_sizes(width: int, height: int) -> list[dict[str, int]]:
    """A halving ladder of thumbnail sizes for level-0 clients."""
    sizes, w, h = [], width, height
    while w > 100 and h > 100:
        w, h = w // 2, h // 2
        sizes.append({"width": w, "height": h})
    return list(reversed(sizes))

Edge cases. For a static level-0 deployment, drop extraQualities/extraFormats, set profile to "level0", and make sure every entry in sizes and every scaleFactors value has a corresponding file on disk — a client will request exactly those and nothing else. For a dynamic server, maxWidth/maxArea are your load safety valve; without them a single full/max/0/default.jpg on a 400-megapixel master can exhaust memory.

2. Parse and validate an incoming request URL

Before serving anything, decompose the path into its four parameters and reject grammar the compliance level does not permit. Parsing is also where a malformed or hostile region gets caught before it reaches the pixel engine.

python
from dataclasses import dataclass


@dataclass(frozen=True)
class ImageRequest:
    identifier: str
    region: str
    size: str
    rotation: str
    quality: str
    fmt: str


def parse_request(path: str) -> ImageRequest:
    """Split a IIIF Image API path into validated components."""
    parts = path.strip("/").split("/")
    if len(parts) != 5:
        raise ValueError("expected identifier/region/size/rotation/quality.format")
    identifier, region, size, rot_qual = parts[0], parts[1], parts[2], parts[4]
    rotation = parts[3]
    quality, _, fmt = rot_qual.partition(".")
    if not fmt:
        raise ValueError("missing format extension")
    _validate_region(region)
    if quality not in {"default", "color", "gray", "bitonal"}:
        raise ValueError(f"unsupported quality: {quality}")
    return ImageRequest(identifier, region, size, rotation, quality, fmt)


def _validate_region(region: str) -> None:
    """Accept only the region forms in the Image API 3.0 grammar."""
    match region.split(":", 1):
        case ["full"] | ["square"]:
            return
        case ["pct", coords] | [coords] if coords.count(",") == 3:
            nums = coords.split(",")
            if all(_is_number(n) for n in nums):
                return
    raise ValueError(f"malformed region: {region}")


def _is_number(token: str) -> bool:
    try:
        float(token)
    except ValueError:
        return False
    return True

Edge cases. A percent-encoded identifier (common when accession numbers carry dots or slashes) must be decoded before it keys into storage, but validated before decoding so an encoded ../ cannot escape the identifier namespace. For a level-0 service, extend _validate_region to reject anything but full and the exact tile regions the pyramid contains.

3. Choose a tile size and scale factors

Tile size and scale factors decide how many round-trips a deep-zoom session costs. A tile that is too small floods the origin with requests; too large and each pan re-downloads more pixels than the viewport shows. 512×512 is the museum-sector default — large enough to keep request counts modest, small enough that a partial viewport does not over-fetch. Scale factors must be powers of two, one per pyramid level, ending when the whole image fits in a single tile; the _scale_factors helper above computes exactly that ladder. The static-tiling deep dive, serving deep-zoom IIIF tiles with pyvips, covers generating the matching pyramid on disk so every advertised scaleFactors value resolves to a real file.

4. Set the compliance level to match the backend

The profile value must not over-promise. Pin it to what the backend can actually serve: level0 for a static pyramid, level2 for a full dynamic server. If you advertise a level but support a feature beyond it — say a static service that also has a gray derivative — declare that in extraFeatures/extraQualities rather than bumping the whole level. Clients read the level as a floor and the extra* arrays as additions, so an honest, minimal profile plus explicit extras is always safer than an inflated level the origin cannot back.

Data Flow

The service answers two request shapes from one identifier: an info.json fetch that advertises capabilities, and a stream of image requests that get parsed and either served from a pre-generated tile or derived on the fly — unless rights gate the asset, in which case a degraded placeholder is returned instead.

IIIF Image API request routing — info.json advertise path and image request path A client first requests info.json. The service reads the master's width and height, computes tiles and scale factors, and returns an info.json document with a compliance level. The client then composes image requests that the service parses into region, size, rotation and quality. A rights gate consults the record's access tier: ungated requests are served either from a pre-generated tile or a derived crop; gated requests are routed to a degraded placeholder derivative instead of the full-resolution pixels. Two paths from one identifier — advertise, then serve or gate Client viewer · Mirador Build info.json width · height · tiles compliance level Parse request region · size rotation · quality Rights gate access tier Serve tile or derivative pyvips crop Placeholder degraded derivative info.json image req ungated gated

The advertise path runs once per object per session and is cheap to cache; the serve path runs dozens of times per zoom and is where tiling, maxWidth caps, and the rights gate earn their keep. Keeping the two paths distinct is what lets an edge cache hold info.json indefinitely while image responses carry their own cache policy — the subject of caching IIIF Image API responses at the edge.

Rights and Access Routing

The Image API is where a rights policy stops being metadata and starts controlling pixels. Every request passes a rights gate that consults the record’s access tier before the pixel engine runs. An object at the public tier serves full-resolution regions; an object whose media is gated — in-copyright, embargoed, or donor-restricted — never returns its master pixels. Instead the gate substitutes a degraded placeholder derivative: a low-resolution, watermarked, or single-sizes-entry image that satisfies the request shape without exposing the asset. The descriptive record and its manifest stay public so the object remains discoverable; only the pixels are withheld.

This tiering is decided upstream, not here. The policy that maps a rights URI and an embargo date to an access tier is specified in routing embargo dates to access tiers; the Image API simply reads the tier the record already carries and routes accordingly. Two rules keep this safe. First, the gate runs before derivation, so a gated master is never decoded into memory only to be discarded. Second, the placeholder must itself be a valid IIIF response — correct region/size dimensions and content type — so the viewer degrades gracefully rather than showing a broken image. The same rights URI that gated the pixels is echoed in the info.json rights property and in the manifest’s requiredStatement, so every layer enforces one consistent policy.

Verification and Testing

Prove the grammar parser and the info.json builder before pointing a viewer at the service. The parser test below exercises a valid tile request, a malformed region, and an unsupported quality; the info.json assertions confirm the advertised tile grid is internally consistent.

python
def test_request_parsing() -> None:
    # 1. A well-formed region/size/rotation/quality request parses.
    req = parse_request("folio-001/125,15,120,140/600,/0/default.jpg")
    assert req.identifier == "folio-001"
    assert req.region == "125,15,120,140"
    assert req.fmt == "jpg"

    # 2. A malformed region is rejected before the pixel engine runs.
    try:
        parse_request("folio-001/12,15,120/max/0/default.jpg")
        assert False, "three-coordinate region should have raised"
    except ValueError:
        pass

    # 3. An unsupported quality is rejected.
    try:
        parse_request("folio-001/full/max/0/sepia.jpg")
        assert False, "sepia is not a IIIF quality"
    except ValueError:
        pass


def test_info_scale_factors() -> None:
    factors = _scale_factors(4000, 3000, tile_size=512)
    # Powers of two until 4000/factor <= 512 -> [1, 2, 4, 8]
    assert factors == [1, 2, 4, 8]
    assert all(f == 2 ** i for i, f in enumerate(factors))


if __name__ == "__main__":
    test_request_parsing()
    test_info_scale_factors()
    print("image API config OK")

Then confirm the live service advertises what you expect. Fetch info.json and check it is valid JSON with the right context, type, and dimensions before any viewer touches it:

bash
curl -s https://iiif.example.org/iiif/3/folio-001/info.json | jq '{context: ."@context", type, profile, width, height, tiles}'

A conformant response echoes http://iiif.io/api/image/3/context.json, ImageService3, the declared compliance level, the true pixel dimensions, and a tiles array whose scaleFactors match the pyramid on disk. Any mismatch between advertised and served capabilities is the defect to fix before a client discovers it as a 404 mid-zoom.

Where to Go Deeper

  • Serving Deep-Zoom IIIF Tiles with pyvips — generates the static pyramid that backs a level-0 service, writing one derivative per tile and scale factor so every value the info.json tiles array advertises resolves to a real file. It covers pyramidal TIFF versus DZI layout, tile-boundary math, and streaming generation that keeps a 400-megapixel master off the heap.

  • Caching IIIF Image API Responses at the Edge — puts a CDN in front of the serve path so a reading-room deep-zoom session hits the origin once per tile, not once per pan. It separates the long-lived cache policy for immutable tiles from the shorter policy for info.json, and shows how the rights gate stays authoritative even behind an edge cache.

FAQ

What is the difference between the Image API and the Presentation API?

The Image API serves pixels: it answers region/size/rotation/quality requests for one image and advertises its capabilities in info.json. The Presentation API serves structure: a manifest describing which images make up an object, their order, labels, and rights. A viewer reads the manifest to lay out the object and calls the Image API service each canvas references to fetch the pixels. You need both, and the manifest’s image service URI must point at a live Image API endpoint.

Which compliance level should a museum service advertise?

Advertise the level your backend can actually serve, and no higher. A static tile pyramid on object storage is level0 — it serves only the exact sizes and tiles it pre-generated. A dynamic server that can crop and scale arbitrarily reaches level2. If you inflate the profile to level2 but only have static tiles, clients will request arbitrary regions and get 404s. When you support one feature beyond your level, declare it in extraFeatures rather than raising the whole level.

How do I choose a tile size and scale factors?

512×512 tiles are the sector default: small enough that a partial viewport does not over-fetch, large enough to keep request counts modest during a zoom session. Scale factors must be powers of two, one per pyramid level, running from 1 up to the factor at which the whole image fits inside a single tile. Every scale factor you list in info.json must have a matching pyramid level on disk or from the dynamic server, or a deep-zoom client will request a level that does not exist.

How does the Image API enforce rights on gated assets?

A rights gate runs before the pixel engine and reads the access tier the record already carries. Public-tier objects serve full-resolution regions; gated objects — in-copyright, embargoed, or donor-restricted — get a degraded placeholder derivative instead of their master pixels, so the object stays discoverable while its pixels stay withheld. The tiering decision itself lives in routing embargo dates to access tiers; the Image API only enforces it.

Why does my viewer request URLs that return 404?

Almost always because info.json promises capabilities the backend cannot serve. A maxWidth higher than the largest derivative, a scaleFactors value with no matching pyramid level, or a level2 profile on a static service all make a conformant client compose URLs the origin cannot answer. Regenerate info.json from the master’s real dimensions and the actual files on disk, then diff the advertised tiles and sizes against what the storage layer holds.