SDKs
Server SDK
@findlane/server uploads and updates catalogs, changes settings, and searches with a workspace API key. It runs in Node.js 18+, Bun, Deno, and edge runtimes.
Install
npm install @findlane/serverCreate a client
import { SearchServerClient } from "@findlane/server"
const client = new SearchServerClient({
workspaceId: process.env.FINDLANE_WORKSPACE_ID!,
apiKey: process.env.FINDLANE_API_KEY!,
})
const products = client.index<Product>("products")| Option | |
|---|---|
workspaceId | The workspace ID, from the API keys or Settings page. |
apiKey | A workspace API key. Keep it on your server. |
baseUrl | The API to call. Leave it out to use Findlane's hosted API. |
fetch | A fetch implementation to use instead of the global one. |
client.index<T>(name) returns the methods for one index. T types your records, which each need a string objectID. Every method returns a promise and takes an optional AbortSignal; the search methods take it as { signal } too.
Search
| Method | Scope | |
|---|---|---|
search(request, options?) | search | Search the index. Takes the same request as the browser SDK, with up to 100 hits, explain, and enableRules. |
suggest(request, options?) | search | For a search that found nothing, a did-you-mean query that finds results, or null. Not counted as a search. |
recommendations(objectID, options?, requestOptions?) | search | A product's recommendations, for indexes with the add-on. Not counted as a search. |
querySuggestions({ q, limit? }, options?) | search | Query suggestions for what a visitor typed: [{ q, typed }]. Not counted as searches (0.16.0). |
popularSearches({ limit? }, options?) | search | The index's popular searches, for an empty search box (0.16.0). |
options (requestOptions for recommendations) takes signal and visitor.
Lists
index.lists reads and sets up the index's lists (0.17.0). Reading isn't counted as searches.
| Method | Scope | |
|---|---|---|
lists.get(id, { limit? }) | search | One list, or a FindlaneError with status 404. |
lists.getMany(ids?, { limit? }) | search | Several, in the order asked, without IDs the index has no list for; all of them without IDs. |
lists.settings() | either | How they're set up, and their last build. |
lists.save(id, list) | write | Creates or changes a list (its settings) and builds it. |
lists.delete(id), lists.order(ids) | write | Deletes a list; puts them in this order. |
lists.refresh() | write | Builds them again now, once a minute. |
lists.preview(list) | write | What a list would hold, without saving it. |
Searching for a visitor
When your server searches for someone, such as a shopper loading a server-rendered search page, pass the visitor (SDK 0.8.0):
const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent: req.get("user-agent") } })The index's settings under Limits and bots on its Public access page then apply to that visitor, as they do to browser search, counted per visitor under your key:
- Searches a minute per visitor applies to each visitor.
- Crawlers get cached results first and a limit of their own, or are blocked, as you chose. Crawlers that say they're Googlebot, Bingbot, Applebot or OpenAI's are checked against the addresses those companies publish, and the verified ones' searches are free.
- Scripts and automation has its own limit, or is blocked.
Data-center networks can't be told apart for visitors you forward, so that setting applies to browser search only. Without visitor, a search counts as your server's own: only the key's searches a minute apply.
Subindexes
index("products").subindex("categories") is the index's subindex, an index of its own named products.categories: write and search it as any index. listSubindexes() lists an index's. A search takes subindexes to search them too, counted as one search (0.25.0).
Events
Orders and other events, for the index's Events page and Bought together (0.9.0). The key needs the events or write scope.
| Method | |
|---|---|
events.send(events, options?) | Send events, 200 a request. Returns { accepted, rejected }; one bad event doesn't refuse the rest. options is a signal, or { signal, visitorId, visitId } with your own anonymous IDs (0.12.0); see Visitor IDs. abTest is the shopper's A/B test group from their search answer (0.32.0). |
events.importOrders(orders, signal?) | Send past orders, up to 400 days old, from a list or an async iterable: { orderID, items, timestamp }. |
events.cancelOrder(orderID, signal?) | Take a cancelled or refunded order out of Bought together and revenue from search. |
events.forget(ids, signal?) | Delete the events sent with these visitor or visit IDs, within a week, 1,000 a request (0.12.0). Returns { forgotten }. |
events.proxy(request, visitor?) | A route on your own site for the browser SDK's events, which ad blockers let through: takes a Fetch API Request, answers a Response (0.11.0). See Through your own domain. |
events.relay(body, visitor) | proxy for servers without Fetch API requests, such as Express: the body as text and { ip, userAgent, origin }. |
await products.events.send([{ type: "purchase", orderID: "1001", items: [{ objectID: "sku-123", quantity: 1 }] }])Each request is retried on network errors and 408, 429, 502, 503, and 504, up to five times, waiting out the rate limit. An order counts once, however often it's sent.
While the index runs an A/B test, the events your server sends count toward a group only when they carry the shopper's. Keep the abTest of the search answer their browser got, with their cart, and send it with their order:
await products.events.send([{ type: "purchase", orderID: "1001", items: [{ objectID: "sku-123", quantity: 1 }] }],
{ abTest: cart.abTest }) // { id, variant }, from the search answerAnalytics
An index's analytics, for showing them in your own admin (0.15.0). The key needs the analytics scope, which no other scope includes, and works only from your server: requests from web pages are refused.
| Method | |
|---|---|
analytics.searches(options?) | Searches, searches without results and response times, beside the same span before, with a count per hour or day. |
analytics.queries(options?) | Queries, the most searched first. list is top (the default) with their clicks, noResults, or noClicks, which found something nobody clicked. limit is 1 to 1,000, 100 by default. |
analytics.filters(options?) | The filter fields and sorts searches used. |
analytics.events(options?) | Events of each type per hour or day and by source, orders stored, and refused and bot events. |
analytics.products(options?) | The most viewed, most added to carts and most ordered products, with titles and images, and those viewed but rarely bought. |
analytics.productCounts(options?) | Every product with events or orders: views, viewers, clicks, add-to-carts and orders, up to 50,000. |
analytics.product(objectID, options?) | One product's counts per hour or day, the queries it was clicked from, and its clicks in recommendations. |
analytics.revenue(options?) | Revenue from search: orders and add-to-carts of products found through search, per query and per product (0.20.0). |
analytics.abTest(options?) | The index's A/B tests, as abTests.get() reads them with a search or write key (0.32.0). |
const week = await products.analytics.searches({ from: "2026-10-01", to: "2026-10-07" })
const empty = await products.analytics.queries({ list: "noResults", range: "30d", limit: 20 })
console.log(week.totals.searches, week.totals.noResultRate, empty.queries[0]?.query)
const panel = await products.analytics.product("sku-123", { range: "30d" })
console.log(panel.totals.views, panel.totals.orders, panel.searchedFor[0]?.query)The options are range (24h, 7d, 30d or 90d; 7 days by default), or from and to as dates or times in UTC, plus source (all, browser or server), interval (hour or day) for the reports with a series, and signal. A date as to includes the whole day. Analytics go back 90 days, and products, productCounts and revenue to 9 October 2026, for up to 13 months. When a report's sampled is true, its counts are estimates; products, productCounts and revenue are exact. See Analytics for the responses.
Records
| Method | Scope | |
|---|---|---|
upsert(records, signal?) | write | Add or replace up to 1,000 records by objectID. |
delete(objectID, signal?) | write | Delete a record. |
getRecord(objectID, signal?) | either | A record and its version; record is null if it doesn't exist. |
upsertOne(record, options) | write | Write one record if it's still at options.expectedVersion. |
deleteOne(objectID, options) | write | Delete one record if it's still at options.expectedVersion. |
replaceAll(records, signal?) | write | Replace the catalog in one request, up to 100,000 records. |
replaceAllStaged(records, options?) | write | Replace the catalog in batches, switched in one commit. |
abortReplacement(stageId, signal?) | write | Drop an unfinished staged replacement. |
Writes return a receipt: { acceptedRevision, indexedRevision, recordCount }, and since 0.39.0 how many records the write added, changed, left unchanged and removed. See Records for how to use them.
replaceAllStaged takes an array or an async iterable of records, and these options:
| Option | |
|---|---|
stageId | Names the stage, so a retry continues it. Default: a random ID. |
batchSize | Records per batch, 1 to 1,000. Default 500. |
maxBatchBytes | Bytes per batch. Default 8 MiB. |
signal | Cancels the upload. |
upsertOne and deleteOne take { expectedVersion, idempotencyKey?, signal? }. The lower-level beginReplacement, uploadReplacementBatch, and commitReplacement are also available for running the stage steps yourself.
Updates
What has come into the index (0.39.0), as its Updates page in the dashboard shows it, for a sync's status in your own admin. The key needs the analytics scope, and works only from your server.
| Method | Scope | |
|---|---|---|
writes.get(options?) | analytics | When data last arrived and last changed anything, totals and a series, each source, and replacements under way. range is 24h (the default), 7d, 30d or 90d. |
writes.log(options?) | analytics | The latest operations, newest first: limit 1 to 200 (50 by default), before an operation's id for older ones, and problems: true for refused ones only. |
const { status, sources } = await products.writes.get({ range: "7d" })
console.log(status.lastData?.lastAt, status.lastChange?.lastAt, sources[0]?.source.label)
const { rows } = await products.writes.log({ problems: true })
for (const row of rows) console.log(row.at, row.source.label, row.reason, row.exampleId)See Updates for the responses.
Settings
| Method | Scope | |
|---|---|---|
getSettings(signal?) | either | The index's settings, and a settings change in progress in rebuild. |
replaceSettings(settings, signal?) | write | Save the full settings. applied says how they took effect. |
waitForSettings(options?) | either | Wait until a background settings change finishes; throws if it failed. |
compareSettings(settings, options?) | write | Compare settings with the index's own without saving them: its search tests and most searched queries, each searched with both. options takes queries, searched: false, and signal. (0.15.0) |
getSearchTests(signal?) | either | The index's search tests. (0.15.0) |
setSearchTests(tests, signal?) | write | Replace the search tests, up to 200; [] deletes them. (0.15.0) |
abTests.get(signal?) | either | The index's A/B tests: the running one with its results so far, or null, and up to 10 that ended. (0.32.0) |
abTests.estimate({ share? }) | either | The days a test at this share (0.1 to 0.5) needs to find a 10% change in clicks per search, and the share more searches that miss the cache. (0.32.0) |
See Search settings for the fields and how changes apply.
compareSettings takes the settings replaceSettings does, or only those that change, and {} runs the tests with the current settings. It returns a SettingsComparison: a summary of the queries whose top 10 changed or that find more or fewer results, every test with before and after (whether it passes with each), and up to 50 changes, each with its totals and the results that left, joined, or moved within its top 10. A change to what the index stores, such as a new facet field, throws a FindlaneError with status 409: save it first. See Preview changes and search tests.
Merchandising
Arrange category pages and searches from your own app, as the dashboard's Merchandising page does (0.23.0). A target is { field, value } for a category page or { q } for a search's exact words.
const index = client.index("products")
await index.merchandising.set({ field: "category", value: "Jackets" }, {
order: ["sku-311", "sku-120"],
hidden: ["sku-098"],
banner: { image: (await index.images.uploadFromUrl("https://example.com/sale.webp")).url, link: "/sale" },
active: { from: "2026-11-27T00:00:00Z", until: "2026-11-30T23:59:00Z" },
})
const { banners } = await index.analytics.banners({ range: "30d" })| Method | Scope | |
|---|---|---|
merchandising.list(signal?) | either | Every arranged category page and search. |
merchandising.get(target, signal?) | either | One target's Arrangement; status is "none" when nothing is arranged. |
merchandising.set(target, change, signal?) | write | Change order, hidden, banner, active or enabled; fields left out stay. |
merchandising.remove(target, signal?) | write | Stop arranging it; the rule's other actions stay. |
images.list(signal?) | either | The index's uploaded images, the rules showing each, and its limits. |
images.upload(image, { contentType? }) | write | Upload a Blob, ArrayBuffer or Uint8Array; returns its url. |
images.uploadFromUrl(url, signal?) | write | Upload the image at an https address. |
images.delete(key, signal?) | write | Delete an image; throws with status 409 while a banner shows it. |
analytics.banners({ range? }) | analytics | Each banner's shown and clicks by its rule, over 7d, 30d or 90d. |
See the merchandising API for the fields and limits.
Product feeds
Connect an index to a store's product feed and follow its runs (0.24.0):
const index = client.index("products")
const check = await index.feed.check({ url: "https://shop.example/feed.xml" })
console.log(check.records, check.reasons)
await index.feed.set({ url: "https://shop.example/feed.xml", schedule: "hourly" })
const [run] = await index.feed.runs()| Method | Scope | |
|---|---|---|
feed.get(signal?) | either | The ProductFeed and its last run, or null. |
feed.set(setup, signal?) | write | Connect or change it: url, username, password, schedule, hour, variants, paused. |
feed.remove(signal?) | write | Disconnect it; the index keeps its records. |
feed.check(setup?, signal?) | write | Read a feed's first 2,000 items without saving anything. |
feed.run(signal?) | write | Read it now, in full, in the background. |
feed.runs(signal?) | either | The last 20 FeedRuns, newest first. |
feed.importHeld(runId, signal?) | write | Import a held run's copy as it is. |
See the product feeds API for the fields.
Indexing status
| Method | Scope | |
|---|---|---|
getIndexingStatus(signal?) | either | { acceptedRevision, indexedRevision }. |
waitUntilIndexed(revision, options?) | either | Wait until a revision is searchable. |
Writes are searchable when they return, so waitUntilIndexed returns at once. It's there so code written for search services that index later keeps working.
Signed keys
For a logged-in person's browser searches, sign a short-lived key with a signing key from the API keys page (0.29.0). It makes no request:
import { signSearchKey } from "@findlane/server"
const signedKey = await signSearchKey(process.env.FINDLANE_SIGNING_KEY!, {
validUntil: new Date(Date.now() + 60 * 60 * 1000),
indexes: ["products"],
filters: { customerGroup: user.group },
returnFields: ["objectID", "title", "price"],
userToken: user.id,
})| Restriction | |
|---|---|
validUntil | When it stops working: a Date or Unix seconds, 7 days ahead at most. Required. |
indexes | The index names it searches, * for any part. Every index the signing key signs for when left out. |
filters | Filters forced onto every search, as in a search request. |
returnFields | The only fields results return. |
userToken | The person, such as their user ID: limits count them rather than their address. |
restrictSources | IP addresses or ranges it works from, such as 192.0.2.0/24. |
referers | Sites it works from, such as https://shop.example.com or *.example.com. |
See Logged-in visitors.
Browser search access
| Method | Scope | |
|---|---|---|
getPublicSearchAccess(signal?) | write | The public ID, allowed origins, and policy. |
setPublicSearchPolicy(policy, signal?) | write | Limit the fields browser search returns, and optionally what it searches, filters, facets and sorts by. |
usePublicIndexDefaults(signal?) | write | Remove the policy. |
Allowed origins are changed in the dashboard. See Security.
Errors
A failed request throws a FindlaneError with the HTTP status and the API's message. replaceAllStaged throws a StagedReplacementError with the stageId to retry or abort, and the original error as its cause.
import { FindlaneError } from "@findlane/server"
try {
await products.upsert(records)
} catch (error) {
if (error instanceof FindlaneError && error.status === 402) alertTeam(error.message)
else throw error
}The staged replacement and conditional write methods retry network errors and 408, 429, 502, 503, and 504 responses up to three times. See the error codes.