Skip to content
Start free

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/server

Create 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
workspaceIdThe workspace ID, from the API keys or Settings page.
apiKeyA workspace API key. Keep it on your server.
baseUrlThe API to call. Leave it out to use Findlane's hosted API.
fetchA 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.

MethodScope
search(request, options?)searchSearch the index. Takes the same request as the browser SDK, with up to 100 hits, explain, and enableRules.
suggest(request, options?)searchFor a search that found nothing, a did-you-mean query that finds results, or null. Not counted as a search.
recommendations(objectID, options?, requestOptions?)searchA product's recommendations, for indexes with the add-on. Not counted as a search.
querySuggestions({ q, limit? }, options?)searchQuery suggestions for what a visitor typed: [{ q, typed }]. Not counted as searches (0.16.0).
popularSearches({ limit? }, options?)searchThe 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.

MethodScope
lists.get(id, { limit? })searchOne list, or a FindlaneError with status 404.
lists.getMany(ids?, { limit? })searchSeveral, in the order asked, without IDs the index has no list for; all of them without IDs.
lists.settings()eitherHow they're set up, and their last build.
lists.save(id, list)writeCreates or changes a list (its settings) and builds it.
lists.delete(id), lists.order(ids)writeDeletes a list; puts them in this order.
lists.refresh()writeBuilds them again now, once a minute.
lists.preview(list)writeWhat 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 answer

Analytics

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

MethodScope
upsert(records, signal?)writeAdd or replace up to 1,000 records by objectID.
delete(objectID, signal?)writeDelete a record.
getRecord(objectID, signal?)eitherA record and its version; record is null if it doesn't exist.
upsertOne(record, options)writeWrite one record if it's still at options.expectedVersion.
deleteOne(objectID, options)writeDelete one record if it's still at options.expectedVersion.
replaceAll(records, signal?)writeReplace the catalog in one request, up to 100,000 records.
replaceAllStaged(records, options?)writeReplace the catalog in batches, switched in one commit.
abortReplacement(stageId, signal?)writeDrop 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
stageIdNames the stage, so a retry continues it. Default: a random ID.
batchSizeRecords per batch, 1 to 1,000. Default 500.
maxBatchBytesBytes per batch. Default 8 MiB.
signalCancels 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.

MethodScope
writes.get(options?)analyticsWhen 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?)analyticsThe 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

MethodScope
getSettings(signal?)eitherThe index's settings, and a settings change in progress in rebuild.
replaceSettings(settings, signal?)writeSave the full settings. applied says how they took effect.
waitForSettings(options?)eitherWait until a background settings change finishes; throws if it failed.
compareSettings(settings, options?)writeCompare 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?)eitherThe index's search tests. (0.15.0)
setSearchTests(tests, signal?)writeReplace the search tests, up to 200; [] deletes them. (0.15.0)
abTests.get(signal?)eitherThe 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? })eitherThe 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" })
MethodScope
merchandising.list(signal?)eitherEvery arranged category page and search.
merchandising.get(target, signal?)eitherOne target's Arrangement; status is "none" when nothing is arranged.
merchandising.set(target, change, signal?)writeChange order, hidden, banner, active or enabled; fields left out stay.
merchandising.remove(target, signal?)writeStop arranging it; the rule's other actions stay.
images.list(signal?)eitherThe index's uploaded images, the rules showing each, and its limits.
images.upload(image, { contentType? })writeUpload a Blob, ArrayBuffer or Uint8Array; returns its url.
images.uploadFromUrl(url, signal?)writeUpload the image at an https address.
images.delete(key, signal?)writeDelete an image; throws with status 409 while a banner shows it.
analytics.banners({ range? })analyticsEach 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()
MethodScope
feed.get(signal?)eitherThe ProductFeed and its last run, or null.
feed.set(setup, signal?)writeConnect or change it: url, username, password, schedule, hour, variants, paused.
feed.remove(signal?)writeDisconnect it; the index keeps its records.
feed.check(setup?, signal?)writeRead a feed's first 2,000 items without saving anything.
feed.run(signal?)writeRead it now, in full, in the background.
feed.runs(signal?)eitherThe last 20 FeedRuns, newest first.
feed.importHeld(runId, signal?)writeImport a held run's copy as it is.

See the product feeds API for the fields.

Indexing status

MethodScope
getIndexingStatus(signal?)either{ acceptedRevision, indexedRevision }.
waitUntilIndexed(revision, options?)eitherWait 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
validUntilWhen it stops working: a Date or Unix seconds, 7 days ahead at most. Required.
indexesThe index names it searches, * for any part. Every index the signing key signs for when left out.
filtersFilters forced onto every search, as in a search request.
returnFieldsThe only fields results return.
userTokenThe person, such as their user ID: limits count them rather than their address.
restrictSourcesIP addresses or ranges it works from, such as 192.0.2.0/24.
referersSites it works from, such as https://shop.example.com or *.example.com.

See Logged-in visitors.

Browser search access

MethodScope
getPublicSearchAccess(signal?)writeThe public ID, allowed origins, and policy.
setPublicSearchPolicy(policy, signal?)writeLimit the fields browser search returns, and optionally what it searches, filters, facets and sorts by.
usePublicIndexDefaults(signal?)writeRemove 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.