API Contracts
Two things live here:
- 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) - 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
/searchand/randomendpoints 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{ "status": "ok", "timestamp": "2026-04-21T10:22:00.000Z" }GET /sources
List sources from the shared manifest.
GET /sources{
"count": 187,
"sources": [
{ "id": "met", "name": "The Metropolitan Museum of Art",
"category": "museums", "region": "US", "access": "open", "imageCount": 470000 }
]
}GET /search
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}| Param | Required | Default | Range |
|---|---|---|---|
q | ✓ | — | Search query |
limit | — | 24 | 1–100 |
{
"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}| Param | Default | Range |
|---|---|---|
count | 6 | 1–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}{
"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" }{
"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" }{
"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:
{ "id": "abc12345",
"url": "https://insposearch.org/?share=abc12345" }Items are stored with a compact schema to minimise KV payload size:
| Key | Maps to |
|---|---|
i | id |
t | thumbnail URL |
n | title |
s | source name |
u | sourceUrl |
y | year |
GET /board/:id{
"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:
{
"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
/searchwas 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 safeFetch → Promise.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.
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.