Searching

Search runs on the same engine as the app's library search: a free-text query is ranked by a hybrid of full-text and semantic similarity, and the complete filter surface from the filter sidebar is available as structured filters.

Requires the search scope.

POST /api/v1/search

JSON body — every field is optional:

{
  "q": "sunset at the beach",
  "mode": "hybrid",
  "filters": {
    "media_type": { "image": "include" },
    "rating": { "3:4": "include", "3:5": "include" }
  },
  "limit": 50,
  "cursor": "eyJvIjo1MH0"
}
FieldTypeDescription
qstringFree-text query. With q, results are relevance-ranked; without it, newest-first.
modestringRanking mode when q is set: hybrid (default), text (full-text only), or vector (semantic only).
filtersobjectStructured filters — see the filter reference below.
limitnumberPage size, 1–100. Default 50.
cursorstringOpaque pagination cursor from a previous response.
curl -X POST https://your-host/api/v1/search \
  -H "Authorization: Bearer na_your_key" \
  -H "Content-Type: application/json" \
  -d '{"q": "sunset at the beach", "limit": 10}'

Response

{
  "items": [
    {
      "id": "0198c9a0-…",
      "status": "ready",
      "original_name": "sunset.jpg",
      "mime_type": "image/jpeg",
      "size_bytes": 2481931,
      "width": 4000,
      "height": 3000,
      "taken_at": "2026-06-30T18:41:02.000Z",
      "created_at": "2026-07-11T09:12:44.201Z",
      "thumbnail_url": "https://…"
    }
  ],
  "next_cursor": "eyJvIjoxMH0"
}

Items have the same shape as GET /api/v1/files/:id, with a signed small thumbnail for ready files. next_cursor is absent when there are no further results.

Pagination

Pass the previous response's next_cursor back unchanged; keep every other body field the same. The cursor is opaque — don't construct or modify it.

async function searchAll(body, apiKey) {
  const items = []
  let cursor
  do {
    const res = await fetch("https://your-host/api/v1/search", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ ...body, cursor }),
    })
    const page = await res.json()
    items.push(...page.items)
    cursor = page.next_cursor
  } while (cursor)
  return items
}

const images = await searchAll(
  { q: "sunset", filters: { media_type: { image: "include" } }, limit: 100 },
  process.env.API_KEY
)

Filters reference

filters mirrors the app's filter sidebar. Most categories are tristate maps: an object whose keys are filter values and whose entries are "include" or "exclude". A missing key is neutral. Within one category, includes are OR-ed; across categories, filters are AND-ed; excludes always subtract.

{
  "filters": {
    "media_type": { "image": "include" },
    "date": { "2026-06": "include" },
    "objects": { "Dog": "exclude" }
  }
}

means: images, taken in June 2026, that don't contain a dog.

Categories and their keys

CategoryKey format
dateCapture date, hierarchical: "2026" (year), "2026-06" (month), "2026-06-30" (day)
import_dateUpload date, same hierarchical format as date
media_type"image", "video", "other"
orientation"landscape", "portrait", "square"
formatAspect ratio: "1:1", "4:3", "3:2", "16:9", "other"
isoBucket keys: "100", "200", "400", "800", "1600", "3200", "6400+"
focal_lengthBucket keys (mm): "<24", "24-35", "35-50", "50-85", "85-200", "200+"
file_typeLowercase file extension, e.g. "jpg", "mp4"
camera_makeCamera manufacturer as recorded in EXIF, e.g. "Canon"
camera_modelComposite "Make:Model", e.g. "Canon:EOS R5"
geography"has_location" or "no_location"
country, state, city, suburbLocation ids (stringified numbers) as used by the app's Locations filter
objectsDetected-object label, e.g. "Dog" — parent labels like "Animal" match all descendants
categoriesCategory ids (stringified numbers); parent categories match their subtree
labelsLabel ids (stringified numbers)
collectionsCollection ids (UUID strings)
ratingComposite "<criterionId>:<stars>", e.g. "3:5" = criterion 3 rated 5 stars
customCustom-field values: "<fieldId>:<value>" for exact match, "<fieldId>:__has__" / "<fieldId>:__empty__" for presence

Id-based categories (labels, categories, collections, rating criteria, custom fields, locations) use the same ids as the app; there is no listing endpoint in v1, so take them from your own records or the app's URLs.

Range filters for number fields

custom_ranges filters number custom fields by min/max (not a tristate map). Keys are field ids; either bound may be omitted:

{ "filters": { "custom_ranges": { "7": { "min": 10, "max": 500 } } } }

Semantic strictness

When q is set, max_distance (0–1) caps how loosely a semantic match may relate to the query — lower is stricter. The default is a balanced cutoff; 1 disables the cutoff entirely.

{ "q": "golden hour", "filters": { "max_distance": 0.4 } }