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"
}
| Field | Type | Description |
|---|---|---|
q | string | Free-text query. With q, results are relevance-ranked; without it, newest-first. |
mode | string | Ranking mode when q is set: hybrid (default), text (full-text only), or vector (semantic only). |
filters | object | Structured filters — see the filter reference below. |
limit | number | Page size, 1–100. Default 50. |
cursor | string | Opaque 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
| Category | Key format |
|---|---|
date | Capture date, hierarchical: "2026" (year), "2026-06" (month), "2026-06-30" (day) |
import_date | Upload date, same hierarchical format as date |
media_type | "image", "video", "other" |
orientation | "landscape", "portrait", "square" |
format | Aspect ratio: "1:1", "4:3", "3:2", "16:9", "other" |
iso | Bucket keys: "100", "200", "400", "800", "1600", "3200", "6400+" |
focal_length | Bucket keys (mm): "<24", "24-35", "35-50", "50-85", "85-200", "200+" |
file_type | Lowercase file extension, e.g. "jpg", "mp4" |
camera_make | Camera manufacturer as recorded in EXIF, e.g. "Canon" |
camera_model | Composite "Make:Model", e.g. "Canon:EOS R5" |
geography | "has_location" or "no_location" |
country, state, city, suburb | Location ids (stringified numbers) as used by the app's Locations filter |
objects | Detected-object label, e.g. "Dog" — parent labels like "Animal" match all descendants |
categories | Category ids (stringified numbers); parent categories match their subtree |
labels | Label ids (stringified numbers) |
collections | Collection ids (UUID strings) |
rating | Composite "<criterionId>:<stars>", e.g. "3:5" = criterion 3 rated 5 stars |
custom | Custom-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 } }