Skip to content

API Contracts

Two things live here:

  1. InspoSearch's own Worker API — the endpoints exposed by api/worker.js. Primarily used by the app; external callers welcome but unsupported right now (see Roadmap for the public-API plan)
  2. External API notes — contract shapes for the biggest upstream sources, so adapter authors don't have to re-reverse-engineer them

Worker base URL: https://insposearch.org · Rate limit: 60 requests/min per IP · CORS: enabled.

Note on shapes. The Worker's /search and /random endpoints use a compact result shape (thumbnail, image, source, sourceUrl) different from the app-internal normalised shape used by the client-side adapters. The client-side shape is documented at the bottom under Client-side normalised shape.

InspoSearch Worker API

GET /health

GET /health
json
{ "status": "ok", "timestamp": "2026-04-21T10:22:00.000Z" }

GET /sources

List sources from the shared manifest.

GET /sources
json
{
  "count": 187,
  "sources": [
    { "id": "met", "name": "The Metropolitan Museum of Art",
      "category": "museums", "region": "US", "access": "open", "imageCount": 470000 }
  ]
}

Worker-side search. Fans out to 6 CORS-friendly sources in parallel (Art Institute of Chicago, Met, Cleveland, Harvard, Rijksmuseum, Flickr Commons). This is a lightweight subset of what the browser app does — the app queries 2,400+ sources via client-side adapters.

GET /search?q={query}&limit={n}
ParamRequiredDefaultRange
qSearch query
limit241–100
json
{
  "query": "vermeer",
  "count": 18,
  "sources": ["Art Institute of Chicago", "The Met Museum", "Cleveland Museum of Art",
              "Harvard Art Museums", "Rijksmuseum", "Flickr Commons"],
  "results": [
    {
      "id": "met_437133",
      "title": "Girl with a Pearl Earring (copy)",
      "artist": "After Johannes Vermeer",
      "date": "late 17th century",
      "medium": "Oil on canvas",
      "tags": [],
      "thumbnail": "https://images.metmuseum.org/.../web-large.jpg",
      "image": "https://images.metmuseum.org/.../original.jpg",
      "source": "The Met Museum",
      "sourceUrl": "https://metmuseum.org/art/collection/search/437133"
    }
  ]
}

GET /random

Random images for the landing page / discovery mode. Internally picks a random term from a curated list and searches 3 sources (Art Institute, Cleveland, Rijksmuseum).

GET /random?count={n}
ParamDefaultRange
count61–24

Response shape matches /search (same result fields).

GET /proxy

CORS proxy for upstream museum/library APIs that refuse browser requests. Strict domain allowlist — only pre-approved domains (Met, Gallica, NYPL, LACMA, Tate, MAK, Mauritshuis, WikiArt, Museum Digital, etc.) are forwarded. Not a general-purpose proxy.

GET /proxy?url={encoded-api-url}

GET /semantic

Expands a query into 8 related visual concepts for query suggestion / expansion. Uses Workers AI (@cf/meta/llama-3.2-1b-instruct) when available, falls back to Datamuse rel_trg + ml endpoints.

GET /semantic?q={query}
json
{
  "query": "vermeer",
  "concepts": ["dutch golden age", "oil painting", "domestic interior",
               "17th century portrait", "chiaroscuro", "genre painting",
               "delft", "pearl earring"],
  "source": "workers-ai"
}

POST /caption

Free-tier image captioning via Workers AI. Fetches the image server-side, runs @cf/uform/uform-gen2-qwen-500m.

POST /caption
Content-Type: application/json
{ "url": "https://…/painting.jpg" }
json
{
  "caption": "Oil painting of a pastoral landscape at dusk, with cattle beside a river.",
  "model": "@cf/uform/uform-gen2-qwen-500m"
}

POST /tags

Primary vision entry point for InspoSearch's free-tier analyse feature. Uses @cf/llava-hf/llava-1.5-7b-hf with @cf/unum/uform-gen2-qwen-500m as fallback.

POST /tags
Content-Type: application/json
{ "url": "https://…/painting.jpg", "query": "optional search context" }
json
{
  "tags": ["oil painting", "pastoral", "19th century", "chiaroscuro",
           "warm earth tones", "cattle", "landscape", "romantic"],
  "description": "…",
  "model": "@cf/llava-hf/llava-1.5-7b-hf"
}

POST /contribute

Opt-in community metadata contribution. Stored in Cloudflare D1 (METADATA_DB binding) when configured.

POST /contribute
Content-Type: application/json
{
  "image_url": "https://…/painting.jpg",
  "source_id": "met",
  "tags": ["oil painting", "17th century"],
  "query": "vermeer",
  "model": "@cf/llava-hf/llava-1.5-7b-hf",
  "consent_token": "…"
}

Contributions are reviewed before entering the shared corpus.

POST /board / GET /board/:id

Shared boards, backed by Cloudflare KV (BOARDS binding). TTL: 30 days. Max 200 items per board.

POST /board
Content-Type: application/json
{ "items": [ { "i": "…", "t": "…", "n": "…", "s": "…", "u": "…", "y": "…" } ],
  "query": "optional" }

Returns 201:

json
{ "id": "abc12345",
  "url": "https://insposearch.org/?share=abc12345" }

Items are stored with a compact schema to minimise KV payload size:

KeyMaps to
iid
tthumbnail URL
ntitle
ssource name
usourceUrl
yyear
GET /board/:id
json
{
  "items": [ /* same compact shape */ ],
  "query": "vermeer",
  "savedAt": "2026-04-21T10:22:00.000Z"
}

Client-side normalised shape

Inside the browser app, each result from any of the 2,400+ client-side adapters is normalised before reaching the UI:

json
{
  "id": "sourceId-recordId",
  "sourceId": "met",
  "title": "…",
  "imageUrl": "…",
  "thumbUrl": "…",
  "sourceUrl": "…",
  "artist": "…",
  "date": "…",
  "medium": "…",
  "description": "…"
}

This is what new adapters in src/fetchers.js must emit.

Why two shapes? The Worker /search was written first as a minimal demo API; the client-side adapters evolved the richer internal shape later. Unifying them is on the Roadmap under the public-API item.


External API notes

Shapes we rely on for the biggest upstream sources. They change occasionally — if an adapter breaks, start here.

Museums

The Metropolitan Museum of Art — two-step (search returns IDs, fetch each by ID):

GET https://collectionapi.metmuseum.org/public/collection/v1/search?q={query}&hasImages=true
  → { total, objectIDs }

GET https://collectionapi.metmuseum.org/public/collection/v1/objects/{id}
  → { objectID, title, primaryImage, primaryImageSmall,
      artistDisplayName, objectDate, medium, tags: [{ term }] }

We batch IDs. Met occasionally requires our image proxy for some thumbnails.

Rijksmuseum (key required for heavy use; demo key usable for light queries):

GET https://www.rijksmuseum.nl/api/en/collection?key={apiKey}&q={query}&ps=40&imgonly=True
  → { artObjects: [{ objectNumber, title, webImage: { url },
                     principalOrFirstMaker, longTitle, links: { web } }] }

Art Institute of Chicago — IIIF image URLs constructed from image_id:

GET https://api.artic.edu/api/v1/artworks/search?q={query}&limit=40
    &fields=id,title,image_id,artist_display,date_display,medium_display,subject_titles
  → { data: [ … ], config: { iiif_url } }

Image URL: {config.iiif_url}/{image_id}/full/843,/0/default.jpg

Cleveland Museum of Art:

GET https://openaccess-api.clevelandart.org/api/artworks/?q={query}&has_image=1&limit=40
  → { data: [{ id, title, images: { web: { url }, print: { url } },
               creators: [{ description }], creation_date, technique }] }

Harvard Art Museums (key required):

GET https://api.harvardartmuseums.org/object?apikey={apiKey}&keyword={query}&hasimage=1&size=40
  → { records: [{ objectid, title, primaryimageurl, people: [{ name }], dated, medium }] }

Archives

Library of Congress:

GET https://www.loc.gov/search/?q={query}&fo=json&c=40
  → { results: [{ title, image_url: [string], url, id }] }

Europeana (key required — unlocks ~2,000 dynamically-discovered sub-providers):

GET https://api.europeana.eu/record/v2/search.json?wskey={apiKey}&query={query}&rows=40
  → { items: [{ id, title: [string], edmPreview: [string],
                dcCreator: [string], year: [string] }] }

Smithsonian Open Access:

GET https://api.si.edu/openaccess/api/v1.0/search?q={query}&rows=40&api_key={apiKey}
  → { response: { rows: [{ title, id,
        content: { descriptiveNonRepeating: { online_media: { media: [ … ] } } } }] } }

DPLA (key required — unlocks DPLA hubs):

GET https://api.dp.la/v2/items?q={query}&page_size=40&api_key={apiKey}
  → { docs: [{ id, sourceResource: { title, creator, date: [{ displayDate }] },
               object, isShownAt }] }

Photography

Unsplash (key in header):

GET https://api.unsplash.com/search/photos?query={query}&per_page=40
Authorization: Client-ID {apiKey}
  → { results: [{ id, description, urls: { regular, thumb },
                  links: { html }, user: { name } }] }

Flickr Commons (key required, image URL pattern-constructed):

GET https://www.flickr.com/services/rest/?method=flickr.photos.search
    &api_key={apiKey}&text={query}&is_commons=1&per_page=40&format=json&nojsoncallback=1
  → { photos: { photo: [{ id, title, server, secret, farm, owner }] } }

Image URL: https://live.staticflickr.com/{server}/{id}_{secret}_z.jpg (thumb) / _b.jpg (large)


Error handling

Every client-side source fetch is wrapped in safeFetchPromise.allSettled. Failures are silent at the user level — successful sources still render — but logged to the console with the source ID and HTTP status, and counted toward the source's health tracker.

js
const results = await Promise.allSettled(
  enabledSources.map(s => safeFetch(s, query, opts))
)

for (const r of results) {
  if (r.status === 'fulfilled') appendResults(r.value)
  else {
    console.warn(`[${r.reason.sourceId}] ${r.reason.message}`)
    recordHealthMiss(r.reason.sourceId, intent)
  }
}

Health misses are scoped by query intent — a source that fails on vermeer (art intent) is not penalised on orchid (nature intent). Misses reset on every new query.

· AGPL-3.0 · app · github