Skip to content
Start free

Reference

HTTP API

Every endpoint the SDKs use, for calling Findlane from any language.

Requests

The API is at https://api.findlane.dev. Requests and responses are JSON: send Content-Type: application/json with every request body.

Index endpoints are under the workspace and index:

/api/teams/{workspaceId}/indexes/{index}

Authenticate with an API key as a bearer token:

API=https://api.findlane.dev
curl "$API/api/teams/$FINDLANE_WORKSPACE_ID/indexes/products/search" \
  -H "Authorization: Bearer $FINDLANE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "q": "trail shoe", "facets": ["brand.name"], "limit": 10 }'

Errors

Failed requests answer with an HTTP status and a message:

{ "error": "Filter field color is not configured" }
StatusMeaning
400The request isn't valid. The message says why.
401The API key is missing or invalid.
402A plan limit: records, indexes, or monthly searches, or the workspace's spending limit. Searches our cache can answer still work.
403The key lacks the scope or index, the origin isn't allowed, or the index's Limits and bots settings block this kind of traffic.
404The index, or a staged replacement, doesn't exist.
409A conflict, such as a record version that changed or another write during a staged replacement.
415The request body isn't sent as application/json.
429A rate limit: per visitor or per index for browser search, per API key, or the workspace's analytics reports. Wait for the Retry-After seconds.
503Too many of the workspace's searches were running at once, the search took longer than 10 seconds, or Findlane couldn't reach its database for a moment (with Retry-After). Retry shortly, or simplify a slow search.
5xxA temporary problem. Retry with a backoff.
MethodPathScope
POST/searchsearchSearch the index.
POST/suggestsearch{ "didYouMean": ... }: a did-you-mean suggestion for a search that found nothing, or null. Not counted as a search.
POST/recommendationssearch{ "objectID": "sku-123" }: a product's recommendations, for indexes with the add-on. Not counted as a search.
POST/query-suggestionssearch{ "q": "hrek", "limit": 5 }: { "suggestions": [{ "q", "typed" }] }, query suggestions for what was typed, or with q empty the popular searches. Not counted as searches.

The body is a search request, with up to 100 hits a request, and the response is a search response. "subindexes" searches the index's subindexes too, as one search. /suggest takes the same request. "explain": true adds why each hit matched and what ordered it; see Explain results. "enableRules": false searches without the index's rules, and "rulesAt" checks them as at a time, in milliseconds since 1970.

POST /api/public/indexes/{publicId}/search
POST /api/public/indexes/{publicId}/suggest
POST /api/public/indexes/{publicId}/recommendations
POST /api/public/indexes/{publicId}/query-suggestions
POST /api/public/search

/api/public/search is a multi-index search: { "queries": [{ "index": "pub_…", "q": "…" }] }, up to 10, counted as one search for each workspace they search.

Browser search needs no key. It answers only requests whose Origin header is one of the index's allowed origins, returns up to 50 hits a request, doesn't take explain, enableRules or rulesAt, and follows the index's public search policy. It supports CORS, so browsers call it directly.

Lists

MethodPathScope
GET/lists/{id}searchOne of the index's lists, or 404. ?limit= gives fewer products or values. Not counted as a search.
GET/listssearch{ "lists": [...] }: the lists ?ids=a,b names (up to 10), in that order, or all of them without ids.
GET/lists/settingseitherHow the lists are set up, each with how many products or values it holds and why its last build failed; the last build; and cap, the lists the plan allows per index.
PUT/lists/{id}writeCreates or changes a list (its settings) and builds it. One that doesn't build is refused with why (400); a new list past the plan's cap gets 402.
DELETE/lists/{id}writeDeletes a list.
PUT/lists/orderwrite{ "lists": ["new", "popular"] }: every list's ID once, in the order /lists answers them.
POST/lists/refreshwriteBuilds the lists again now; once a minute, else 429 with Retry-After.
POST/lists/previewwriteA list's settings in the body: what it would hold, without saving it.

The changes answer with the lists' settings, as /lists/settings does. List IDs are lowercase letters, digits and hyphens, and can't be settings, refresh, preview or order.

From browsers, without a key, from the index's allowed origins:

GET /api/public/indexes/{publicId}/lists/{id}
GET /api/public/indexes/{publicId}/lists?ids=popular,themes

Events

MethodPathScope
POST/eventsevents or write{ "events": [...] }: up to 200 views, clicks, add-to-carts, orders, and cancels.

From browsers, without a key, up to 100 events a request from the index's allowed origins:

POST /api/public/indexes/{publicId}/events

A server passing on a visitor's events from its own domain sends them to /events with its key and findlane-visitor-ip, plus findlane-visitor-agent, findlane-visitor-origin and findlane-visitor-country when it has them. They're then taken as browser events, limited per visitor. See Through your own domain.

Both answer 202 with { "accepted": 3, "rejected": [{ "index": 1, "reason": "..." }] }: one bad event doesn't refuse the rest. Events aren't counted as searches. See Events.

visitorId and visitId beside events link a shopper's events, when the index takes visitor IDs. { "forget": ["…"] } instead of events deletes the events sent with those IDs within a week: up to 2 IDs from browsers, 1,000 from servers.

Analytics

MethodPathScope
GET/analytics/searchesanalyticsSearches, searches without results and response times, beside the same span before, with a count per hour or day.
GET/analytics/queriesanalyticsQueries, the most searched first: ?list=top (the default) with their clicks, noResults, or noClicks. &limit= is 1 to 1,000, 100 by default.
GET/analytics/filtersanalyticsThe filter fields and sorts searches used.
GET/analytics/eventsanalyticsEvents of each type per hour or day and by source, orders stored, and refused and bot events.
GET/analytics/productsanalyticsThe most viewed, most added to carts and most ordered products, with titles and images, and those viewed but rarely bought.
GET/analytics/product-countsanalyticsEvery product with events or orders: views, viewers, clicks, add-to-carts and orders, for a table beside your own sales.
GET/analytics/products/{objectID}analyticsOne product's counts per hour or day, the queries it was clicked from, and its clicks in recommendations.
GET/analytics/revenueanalyticsRevenue from search: orders and add-to-carts of products found through search, per query and per product.
GET/analytics/search-termsanalyticsSearch terms for ads and landing pages: the searches that sell, and those that found nothing, with why. format=csv and list=selling or list=missing download one list as CSV.
GET/analytics/brandsanalyticsBrands: each brand's clicks on search results, and its products' add-to-carts, times bought and revenue from search. field picks the record field brands are in; format=csv downloads it.
GET/analytics/filter-salesanalyticsSales by filter value: each facet value's clicks, add-to-carts, orders and revenue.

An index's analytics, for showing them in your own admin. Only the analytics scope reads them. Call them from your server: requests from web pages, which carry an Origin header, are refused, so a key put in browser code fails at once. They aren't counted as searches.

The window is ?range= 24h, 7d (the default), 30d or 90d, ending now, or ?from= and &to= in UTC:

  • A date such as 2026-10-01 is the start of that day, and as to it includes the whole day. A time such as 2026-10-01T12:00:00Z is taken as it is.
  • to is now by default, and a window reaching past now ends now.
  • Analytics go back 90 days. A window starting earlier starts then, and the response's from says where.
  • /analytics/products, /analytics/product-counts and /analytics/revenue count every event kept, which is 13 months, from 9 October 2026 10:20 UTC. /analytics/filter-sales starts on 26 October 2026, when clicks started carrying their filters.

&source= is all (the default), browser for searches and events from browsers, or server for those sent with API keys. Search reports leave out crawlers' searches, verified or not, from 16 October 2026; they're counted under Analytics → Traffic. &interval=hour or day sets the buckets of the reports with a series: hours up to 7 days and days beyond, by default.

{
  "index": "products",
  "from": "2026-10-01T00:00:00.000Z",
  "to": "2026-10-08T00:00:00.000Z",
  "source": "all",
  "sampled": false,
  "interval": "hour",
  "totals": { "searches": 1840, "tracked": 1790, "noResults": 107, "noResultRate": 0.06, "browser": 1612, "server": 228,
    "uniqueQueries": 604, "medianMs": 38, "p95Ms": 121 },
  "previous": { "from": "2026-09-24T00:00:00.000Z", "to": "2026-10-01T00:00:00.000Z", "searches": 1702, "...": "..." },
  "series": [{ "t": "2026-10-01T00:00:00.000Z", "searches": 4, "tracked": 4, "noResults": 0 }]
}
  • Every report starts with index, from, to, source and sampled. When sampled is true, some of the counts were sampled, so they're estimates: totals and lists stay close, and small counts less so.

  • tracked is the searches whose query and result count were recorded, the base for noResultRate. medianMs and p95Ms are the API's response times.

  • series has every hour or day of the window, with zeros where there were no searches. The first bucket starts at or before from.

  • /analytics/queries answers { "list", "queries": [...] }. Queries are as searched, in lowercase, up to eight words. Top queries are { "query", "searches", "noResults", "clicks", "averagePosition", "middlePosition" }: clicks on their results, the clicks' average place, and the middle click's place, which a few clicks far down don't pull up. Places are from 1, and each is null when clicks aren't available. The other lists are { "query", "searches" }. Clicks come from click events with query and position, and noClicks leaves out queries typed on the way to one that was clicked.

  • /analytics/filters answers { "searches", "filters": [{ "field", "searches" }], "sorts": [{ "sort", "searches" }] }, with sort null for searches that named none and used the index's default.

  • /analytics/events answers { "types": [{ "type", "browser", "server" }], "orders": { "accepted", "stored" }, "bots", "refused": [{ "reason", "events" }], "series": [{ "t", "view", "click", "addToCart", "purchase", "cancel" }] }.

Products are { "objectID", "views", "viewers", "searchClicks", "carts", "orders" }:

  • viewers is how many visitor IDs viewed it, or null when the index's views carry none.
  • searchClicks is clicks on it outside recommendations, in search results and product lists.
  • orders is the orders stored with it, imported ones too.

/analytics/products and /analytics/product-counts are exact, and add computedAt, when they were counted (up to 5 minutes ago), and orders, the orders in the window:

  • /analytics/products lists products, with each one's title and image from its record, and viewedNotBought: products with 50 or more views, the fewest orders per view first, or null without orders.
  • /analytics/product-counts lists every product with events or orders in products, the most viewed first, up to 50,000. complete is false when there were more.

/analytics/revenue is exact too, with computedAt, and counts every source, so its source is all. Amounts are in currency, the one most orders use:

  • orders and revenue are the window's orders and their value, and searchOrders and searchRevenue those with a product the shopper clicked in search results in the 24 hours before. carts, cartValue, searchCarts and searchCartValue are the same for add-to-carts, and searchClicks is clicks on search results.
  • opened is { "orders", "revenue", "carts", "cartValue", "clicks", "byKind": { "ad", "email", "link" } }: orders whose last search click was on a search an ad, an email or another site opened, left out of the search numbers.
  • cancelled is { "orders", "revenue" }: orders in the main currency cancelled or refunded since, left out of every number.
  • queries lists up to 100 { "query", "searches", "clicks", "carts", "orders", "revenue", "openedRevenue", "typed" }, the most revenue first. searches is null when the query isn't among the 1,000 most searched, and typed lists shorter queries typed on the way, whose clicks count here.
  • products lists up to 50 { "objectID", "title", "image", "orders", "revenue", "searchOrders", "searchRevenue", "openedRevenue", "topQuery" } bought after a search, the most revenue from search first.

/analytics/search-terms answers { "index", "from", "to", "computedAt", "currency", "selling", "missing" }. selling lists up to 100 { "query", "searches", "clicks", "carts", "orders", "revenue" } whose clicked products were added to carts or bought, the most revenue first, as in /analytics/revenue, and is null where revenue from search isn't set up. missing lists up to 500 { "query", "searches", "cause", "word" } searched at least 3 times that found nothing, the most searched first, without searches still being typed or junk; cause is "not-sold", "other-word" (with word, what the store calls it) or null before it's checked.

/analytics/brands is exact too, from 9 October 2026, and answers { "index", "from", "to", "computedAt", "field", "fields", "currency", "totals", "brands", "others" }. field is the record field brands came from (null when none was found; pick one of fields with field=), brands lists up to 50 { "brand", "searches", "clicks", "carts", "bought", "revenue" }, the most revenue first, and others adds up the rest with their number, brands. totals has the clicks and revenue of every product counted, for shares.

/analytics/filter-sales is exact too, with computedAt, since and currency. fields lists each facet field shoppers filtered on, { "field", "search", "page" }, with up to 20 values each on searches and on category pages, { "value", "clicks", "carts", "orders", "revenue" }, the most revenue first, and of ranges the 10 most clicked. unfiltered is the same for searches without filters.

  • otherCurrencies lists orders in other currencies, and coverage says what could be linked: { "linkedOrders", "linkedClicks", "linkedSince", "visitorIds", "unpricedLines" }.

/analytics/products/{objectID} is fast, for a product page in your admin. It answers { "objectID", "totals", "series", "searchedFor": [{ "query", "clicks" }], "recommendationClicks": { "goesWith", "similar", "together" } }, with each bucket's counts in series. Its views and clicks are estimates when sampled, and an add-to-cart with several products counts toward the first one only. Its orders are exact.

/analytics/ab-test answers { "index", "running", "ended" }: the index's running A/B test with its results so far, and those that ended, as below. It takes no window.

Analytics are about a minute behind, and product counts about 2 minutes. A report is kept for 30 seconds, and for 10 minutes once its window has ended. A workspace can have 60 reports counted a minute, and kept reports don't count toward that.

Records

MethodPathScopeBody
POST/recordswrite{ "records": [...] }: add or replace 1 to 1,000 records, all or none.
PUT/recordswrite{ "records": [...] }: replace the catalog with up to 100,000 records.
DELETE/records/{objectID}write
GET/records/{objectID}either
POST/record-writeswriteA conditional write, below.

Writes answer 202 with a receipt, and are searchable when they do:

{ "acceptedRevision": 42, "indexedRevision": 42, "recordCount": 18230, "added": 3, "changed": 12, "unchanged": 985, "removed": 0 }

added, changed, unchanged and removed say what the write did: records new to the index, records whose values changed, records sent exactly as the index holds them, which change nothing, and records deleted. Conditional writes and a staged replacement's commit have them too.

GET /records/{objectID} answers { "objectID", "version", "record" }, where record is null if the record doesn't exist.

Conditional writes

{
  "operation": "upsert",
  "objectID": "sku-1042",
  "record": { "objectID": "sku-1042", "title": "Trail running shoe", "price": 119 },
  "expectedVersion": 3,
  "idempotencyKey": "price-update-8f14e45f"
}

The write applies only if the record's version is expectedVersion (0 for a record that doesn't exist), and answers 409 otherwise. operation is upsert or delete; a delete has no record. Repeating a request with the same idempotencyKey (1 to 64 letters, digits, _, or -) returns the first receipt instead of writing again.

Staged replacements

A staged replacement uploads a catalog in batches and switches the index to it in one commit. stageId and batchId are 1 to 64 letters, digits, _, or -.

The paths are under /staged-replacements/{stageId}, and need the write scope:

MethodPathBody
POST/staged-replacements/{stageId}Start the stage, or get its status.
POST…/batches/{batchId}{ "records": [...] }: 1 to 1,000 records.
POST…/commit{ "expectedBatches": 12, "expectedRecords": 11408 }
GET/staged-replacements/{stageId}The stage, and what committing it now would do: added, changed, unchanged and removed records.
DELETE/staged-replacements/{stageId}Abort the stage.

Uploading a batch again with the same records has no effect; with different records it fails with 409. The commit checks the batch and record counts, and fails with 409 if other writes reached the index since the stage started; with "keepNewer": true, it commits anyway, and records written or deleted since the stage started keep that newer state. Committing again returns the first receipt.

A stage of more than 20,000 records commits in steps, and the commit answers once they're all done. While it commits, the stage's status is committing, other writes to the index wait for it (record writes go ahead with "keepNewer": true, and keep their state), and DELETE fails with 409. Committing again waits for the same commit.

Updates

What has come into the index, as the dashboard's Updates page shows it: for a sync's status in your own admin, or to check what an upload did. Like analytics, these need the analytics scope and work only from your server.

MethodPathScope
GET/writesanalytics?range= 24h (the default), 7d, 30d or 90d: totals, a series, each source, and replacements under way.
GET/writes/loganalyticsThe latest operations, newest first. ?limit= is 1 to 200 (50 by default), &before= an operation's id for older ones, and &problems=1 gives refused ones only.

/writes answers { "range", "status", "totals", "series", "sources", "underWay" }:

  • status has lastData, the last operation that sent records, whether or not it changed any; lastChange, the last that changed something; and lastProblem, the last refused. It also has acceptedRevision, indexedRevision, the revision searches everywhere have, and lagMs, how long the oldest change they don't have yet has waited.
  • totals and each of sources count operations, refused and bytes, and the records sent, added, changed, unchanged and removed. A source is { "kind", "label" }. Its kind is key (labelled with the key's name now, or Deleted key), woocommerce, dashboard, mcp (with the app's name), feed, shopify, system (Findlane's own imports) or unknown.
  • series has a point an hour for 24h, one every 6 hours for 7d, and one a day for longer ranges, oldest first.
  • underWay lists replacements being uploaded (open) or committed in steps (committing), with done of total changes so far.

/writes/log answers { "rows", "more" }, and each operation is { "id", "at", "lastAt", "repeats", "source", "op", "status", "reason", "exampleId", "sent", "added", "changed", "unchanged", "removed", "revision" }:

  • Alike operations in a row are one, with repeats from at to lastAt, so a sync that changes nothing every 15 minutes takes one row until something changes.
  • op is upsert, delete, conditional, replacement, settings or load.
  • A refused operation has its status (400 and up) and reason, and exampleId is a refused record's objectID. Record values are never kept.

An index keeps its hourly totals for 90 days, and its latest 500 operations and 200 refused ones. The numbers start on 6 November 2026, and /writes is kept for 5 seconds.

Settings

MethodPathScope
GET/configeitherThe settings, with a change in progress in rebuild.
PUT/configwriteSave the full settings. The response adds applied, rebuilt, and rebuild.
GET/fieldssearchField paths in the index's records, with their types and how many records have each.
GET/suggestionseitherSuggested settings for the index's kind of data: settings, each field's role, and the kind used. ?kind= suggests for another kind. Once the settings have been changed, only { "configured": true, "kind" }.
PUT/kindwrite{ "kind": "products" }: what the index holds (products, docs, articles, listings, other, or null).
GET/indexingeither{ "acceptedRevision", "indexedRevision", "recordCount" }
GET/query-suggestions/statuseitherThe query suggestion settings, and the list's last build: builtAt, entries, how many came from shoppers' searches (searched) and the catalog, the catalog fields used, and error.
POST/query-suggestions/rebuildwriteRebuild the query suggestion list now, and answer as the status does.
POST/comparewriteCompare settings with the saved ones without saving them, below.
GET/testseither{ "tests": [...] }: the index's search tests.
PUT/testswrite{ "tests": [...] }: replace the search tests, up to 200; [] deletes them. A test the index can't run, such as one filtering a field that isn't a facet or filter field, is refused with 400 and a message that names it.

A/B tests

MethodPathScope
GET/ab-testseither{ "running", "ended" }: the running A/B test with its results so far, or null, and up to 10 that ended, newest first.
POST/ab-testsadminStart one: { "settings": {...} }, the settings to test as PUT /config takes them, or { "aa": true }; with share (0.1 to 0.5, 0.5 by default), days (7 to 60, 14 by default) and name. 409 while another runs, or for a change the index can't serve side by side.
GET/ab-tests/estimate?share=either{ "searchesPerDay", "clicksPerSearch", "days", "extraMisses", "extraSearchesPerDay" }: the days a test needs to find a 10% change in clicks per search (null with too few), how many more searches miss the cache (0.3 is 30% more), and about how many a day, from the last 7 days (null with too few).
POST/ab-tests/{id}/endadmin{ "keep": "a" } keeps today's settings, { "keep": "b" } applies the change.
POST/ab-tests/{id}/applyadminApplies an ended test's change to today's settings.

A test is { "id", "name", "share", "changes", "aa", "patch", "startedAt", "endsAt", "endedAt", "outcome", "results" }:

  • changes names the settings it changes, and patch holds their values for group B (null where it goes without one).
  • outcome is "a" (kept today's settings), "b" (applied the change) or "guard" (ended early: the change made searches find nothing far more often).
  • results is { "days", "decided", "verdict", "searches": { "a", "b" }, "currency", "figures", "perDay", "computedAt" }.
    • figures has noResults, clicks, topThree, carts, orders and revenue, each { "a", "b", "change", "low", "high" }: each group's value per search (noResults and topThree are shares), and B's change with its 95% interval.
    • verdict is "b", "a" or "none" once decided, and null before.

Starting, ending and applying a test take an owner or admin signed in to the dashboard, or their agent on the MCP server; API keys read tests but don't change them.

A browser search on an index running a test answers with abTest: { "id", "variant" }, the shopper's group. An events request may send it back as abTest: "<id>:<variant>", as the browser SDK does. A server's own events count toward a group only with it.

Agents use start_ab_test, get_ab_test and end_ab_test on the MCP server.

Merchandising

Category pages and searches arranged as the dashboard's Merchandising page arranges them, for building it into your own admin. Each is saved as that page's rule, which applies within seconds and keeps anything else the rule does, such as a boost. A target is a category page, field and value (such as category and Jackets), or a search's exact words, q.

MethodPathScope
GET/merchandisingeither{ "arrangements": [...] }: every arranged category page and search.
GET/merchandising/arrangementeitherOne target's arrangement, from ?field=&value= or ?q=; status is none when nothing is arranged.
PUT/merchandising/arrangementwriteChange one: the target, and any of order, hidden, banner, active, enabled. Fields left out stay as they are.
DELETE/merchandising/arrangementwriteStop arranging one, from ?field=&value= or ?q=: its order, hidden products and banner go.
GET/imageseitherThe index's uploaded images, the rules showing each, and its limits.
POST/imageswriteUpload an image: its bytes as the body, or JSON { "url" } (an https address Findlane fetches) or { "data" } (base64).
DELETE/images/:keywriteDelete an image; refused with 409 while a rule's banner shows it.
GET/bannersanalyticsEach banner's shown and clicks by its rule, over ?range= 7d, 30d (the default) or 90d.
{
  "field": "category",
  "value": "Jackets",
  "order": ["sku-311", "sku-120"],
  "hidden": ["sku-098"],
  "banner": { "image": "https://images.findlane.dev/…/….webp", "link": "/sale", "title": "Winter sale", "position": 1 },
  "active": { "from": "2026-11-27T00:00:00Z", "until": "2026-11-30T23:59:00Z" }
}
  • order: up to 20 objectIDs that come first, at places 1 to N; the rest follow the index's ranking. [] for none.
  • hidden: up to 100 objectIDs left off the page. A hidden product leaves the order.
  • banner: an https image (one uploaded here, or your own), and optionally a link (https, or a path such as /sale), a title, and the place it shows before; null for none.
  • active: a campaign's times, ISO 8601 with their zone; null for always. enabled: false turns it off without losing it.

An arrangement answers with its target, rule, order (null when the rule's pins have gaps, set through rules, and listed in pins), hidden, banner, active, enabled, and status: live, scheduled, ended, off, or none. Images are JPEG, PNG, WebP or AVIF, up to 2 MB, 50 and 100 MB an index; one no rule shows is deleted a week later.

Product feeds

An index's product feed: a store's Google or Meta feed, or a JSON file of records, read on a schedule.

MethodPathScope
GET/feedeither{ "feed": ... }: the feed and its last run, or null.
PUT/feedwriteConnect a feed or change it: any of url, username, password, schedule, hour, variants, paused.
DELETE/feedwriteDisconnect it; the index keeps its records.
POST/feed/checkwriteRead a feed's first 2,000 items without saving anything: { "url" }, or nothing for the connected feed.
POST/feed/runswriteRead the feed now, in full, in the background: 202 with the run.
GET/feed/runseither{ "runs": [...] }: the last 20, newest first.
POST/feed/runs/{runId}/importwriteImport a held run's copy of the feed as it is: 202 with the new run.
{
  "url": "https://shop.example/feed.xml",
  "username": "findlane",
  "password": "…",
  "schedule": "daily",
  "hour": 4,
  "variants": "group"
}
  • url: https, or http without a password, on a named host. A new address, sign-in or variants reads the feed at once.
  • username, password: for a feed behind HTTP basic sign-in; null clears one. The password is stored encrypted, and GET says only whether there is one.
  • schedule: hourly (the default), 6h, or daily at hour (0 to 23, UTC).
  • variants: group (the default) makes the items sharing an item_group_id one record listing them; separate keeps a record each.
  • source: file (the default), one file at the address; or pages, a JSON endpoint answering { records, next } a page at a time (see paged sources).
  • paused: true stops the runs; false resumes them, as after a week of failures.

A run has a status: running, unchanged (the file hadn't changed), imported, held (not imported: message says why), or failed. With them come items read, records made, records added, changed and removed, and refused items, with reasons (how many for each) and refusals (the first 100, with their IDs). A feed also says whether it's paused and why (owner or failing), its failures in a row, nextRunAt, and held, the run Import anyway would import.

Comparing settings

POST /compare searches with the saved settings and with the ones sent, without saving them: the index's search tests, the queries sent, and unless searched is false, its 100 most searched queries of the last 30 days. It runs for up to 20 seconds and doesn't count as searches.

{
  "settings": { "rules": [] },
  "queries": ["blöðrur", { "q": "kerti", "filters": { "category": "Candles" } }],
  "searched": true
}

settings takes what PUT /config does, or only the settings that change; {} runs the tests with the saved settings. The response:

{
  "compared": 102,
  "truncated": false,
  "searches": 20419,
  "summary": {
    "topChanged": { "queries": 2, "searches": 32 },
    "moreResults": { "queries": 0, "searches": 0 },
    "fewerResults": { "queries": 2, "searches": 32 },
    "nowNothing": { "queries": 0, "searches": 0 },
    "nowSomething": { "queries": 0, "searches": 0 }
  },
  "tests": [
    { "id": "halloween-costumes", "q": "hrekkjavökubúningar", "expect": { "minResults": 100 }, "before": true, "after": false }
  ],
  "changes": [
    {
      "q": "hrekkjavökubúningar",
      "searches": 30,
      "before": { "total": 1247, "top": [{ "objectID": "witch-hat", "title": "Witch hat" }] },
      "after": { "total": 1, "top": [{ "objectID": "card-halloween", "title": "Halloween card" }] },
      "gone": [{ "objectID": "witch-hat", "title": "Witch hat", "from": 1, "to": null }],
      "added": [{ "objectID": "card-halloween", "title": "Halloween card", "from": 14, "to": 1 }],
      "moved": []
    }
  ],
  "errors": []
}
  • tests: every search test, with before and after: whether it passes with the saved settings and with the ones sent. skipped: true when time ran out before it ran.
  • changes: up to 50 queries whose top 10 or number of results changed, the tests' queries first, then the most searched. top is the first 5 results; from and to are places in the first 20, and null is outside them.
  • truncated: time ran out before every query was compared.

Settings that change what the index stores (new searchable, facet, filter, sort, ranking, or location fields, or text normalization) answer 409: they can only be compared after saving. 503 means too many of the workspace's searches were running; retry shortly.

Browser search access

MethodPathScope
GET/public-accesswrite{ "publicId", "allowedOrigins", "policy", "imageHosts", "imageField" }
PUT/public-access/policywrite{ "policy": { "returnFields": [...] } }, and optionally the other lists: see Security.
DELETE/public-access/policywriteRemove the policy.
PUT/public-access/imageswriteOptimized images, on paid plans: { "hosts": ["cdn.shop.example.com"], "field": "image" }. [] turns them off.
GET/public-access/image-hostswriteThe image hosts and fields found in a sample of the records, placeholder progress, and the month's new copies.

Optimized images are included in every paid plan. Findlane resizes the images in an index's records to the size a page shows them at and sends them as WebP from images.findlane.dev, and each hit carries a placeholder: a blurred preview of its image, shown until it loads. The browser SDK's Image component, client.imageUrl and the search widget use them.

  • hosts: the hosts the records' image addresses are on. Only images on these hosts that are in the index's records are resized; anything else loads as it is.
  • field: the record field placeholders are made from: image unless you set another, with a list's items as images.src.
  • Copies: each plan includes 5 new resized copies a month per record it includes, one for each photo and width shown. Copies already made load free and don't count. Past it, photos without a copy yet load as their originals until the next month.
  • Public answers name the hosts in x-findlane-images, and where images load from in x-findlane-image-origin.
  • On Free, setting hosts answers 402.

Indexes, API keys, and allowed origins are managed in the dashboard, or by an AI agent through the MCP server.