Everything you need to implement search with Dbrij Search: indexes, documents, querying, filters, facets, relevance settings and limits.
Search your users can feel: typo tolerant, faceted and ranked, answered in milliseconds through one API call. You push JSON documents into an index and query them. Your documents, your fields, your relevance.
There are three things to know before anything else. An index is a named collection of documents with its own search rules. A document is any JSON object with an id, and sending the same id again replaces it. A search only key can read an index and do nothing else, which is what makes it safe to ship in a browser.
https://api.dbrij.com/apiEvery response is wrapped as { "success": true, "data": … }, the same envelope the rest of the Dbrij API uses. The examples below show the payload inside data.
Three calls from nothing to a working search.
curl -X POST https://api.dbrij.com/api/search/indexes \
-H "Authorization: Bearer $DBRIJ_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "products", "settings": { "filterableFields": ["brand", "price"], "sortableFields": ["price"] } }'curl -X POST https://api.dbrij.com/api/search/indexes/products/documents \
-H "Authorization: Bearer $DBRIJ_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "documents": [
{ "id": "1", "title": "Running shoes", "brand": "Nomad", "price": 45000 },
{ "id": "2", "title": "Trail runners", "brand": "Ardent", "price": 62000 }
] }'Check what came back, not just the status. A write reports itself honestly and your importer should read it, because "the request worked" and "your documents are in" are different claims:
const res = await fetch(`${BASE}/search/indexes/products/documents`, { method: 'POST', headers, body });
const result = await res.json();
// 201 all landed · 207 some did not · 422 none did.
if (result.failed > 0) {
// Every entry says which document and the engine's reason, cause included.
console.error(`${result.failed} of ${result.failed + result.indexed} rejected`, result.errors);
throw new Error('Sync incomplete');
}const res = await fetch(`${BASE}/search/indexes/products/query`, {
method: 'POST',
headers: {
// A search only key. It can read this index and nothing else, which is
// why it is fine for your users to see it.
Authorization: 'Bearer dbrij_live_...',
'Content-Type': 'application/json',
},
body: JSON.stringify({ q: input.value, facets: ['brand'], highlight: true }),
});
const { data } = await res.json();
render(data.hits);Call it on every keystroke. A query is milliseconds of work, and search that waits for a button press stops feeling like search.
Pass a key as Authorization: Bearer dbrij_live_…. Keys are made in the dashboard under API keys and come in two kinds, and the difference is the most important thing on this page.
KEY KINDS
| search only | search:query | Reads indexes. Cannot write a document, change relevance or delete anything. Ship it in your JavaScript, your mobile app, anywhere your users can read it. |
| admin | search:write, search:manage | Writes documents, changes settings, and reads Analytics (which needs search:manage, so a search only key cannot call it). Keep it on your server, in your secret manager, and never in a browser bundle. |
We store a hash of every key, so the full value is shown once at creation and is genuinely unrecoverable afterwards. Revoking takes effect within seconds. If an admin key ever reaches a public repository, revoke it first and investigate second.
One index, many tenants: scoped tokens. A search only key can read its whole index, which is not isolation when one index holds many customers\' rows, because anyone can open devtools and drop your filter. A scoped token fixes that: your backend mints it from a search key, the filter travels inside it, signed, and the browser gets the token instead of the key. The server verifies the signature and ANDs the embedded filter into every query; the client cannot loosen it.
import { createHash, createHmac } from 'node:crypto';
function scopedToken(rawKey, keyId, { filter, indexes, expiresInSeconds }) {
const payload = Buffer.from(JSON.stringify({
k: keyId, // the search key's id (shown in the dashboard)
f: filter, // e.g. tenantId = "t_42", ANDed into every query
ix: indexes, // e.g. ['products'], the only indexes it may touch
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
})).toString('base64url');
const secret = createHash('sha256').update(rawKey).digest('hex');
const sig = createHmac('sha256', secret).update(payload).digest('base64url');
return `dbsst_${payload}.${sig}`;
}
// Hand the token to the browser; it calls /query with
// Authorization: Bearer dbsst_…
// exactly as it would with a key.The signing secret is the sha256 of your raw key: your server can derive it, Dbrij stores exactly that hash, and the person holding the token has neither. A scoped token can only ever query, whatever the parent key could do, and revoking the parent key kills every token minted from it at once.
Name an index after what it holds: products, articles, customers. Create one per kind of thing rather than one big index with a type field, because relevance settings are per index and mixing kinds means tuning for both at once.
/search/indexessearch:manageCreate an index
An index holds documents and the rules for searching them. Every setting has a working default, so a bare name is a valid call.
PATH / BODY PARAMETERS
| name* | string | 2 to 64 characters: lowercase letters, numbers, dashes and underscores. This is what every path uses. |
| settings | object | Optional relevance settings, see Relevance. Anything omitted keeps its default. |
curl -X POST https://api.dbrij.com/api/search/indexes \
-H "Authorization: Bearer $DBRIJ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "products", "settings": { "searchableFields": ["title", "description"], "filterableFields": ["category", "price"] } }'{ "name": "products", "settings": { "searchableFields": ["title", "description"], "filterableFields": ["category", "price"] } }{
"success": true,
"data": { "id": "3f…", "name": "products", "documentCount": 0, "status": "ready" }
}/search/indexessearch:manageList indexes
Every index in the workspace with its document count, size and settings.
curl -X GET https://api.dbrij.com/api/search/indexes \
-H "Authorization: Bearer $DBRIJ_API_KEY"{
"success": true,
"data": [{ "name": "products", "documentCount": 1240, "sizeBytes": 918273, "status": "ready" }]
}/search/indexes/:name/settingssearch:manageChange relevance settings
Field lists, ranking, typo, matchFields and matchingStrategy settings apply to the next query with no rebuild. Changing synonyms, stop words, language, geoFields, splitLetterDigit or matchJoinedWords rebuilds the index in the background: the call returns immediately with status "rebuilding", queries keep working on the old rules, and the index flips to "ready" when the copy finishes. Poll GET /search/indexes to watch it. The response echoes every setting as stored, so compare it with what you sent. Sending the same settings again is a no-op and does not rebuild.
curl -X PUT https://api.dbrij.com/api/search/indexes/:name/settings \
-H "Authorization: Bearer $DBRIJ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "settings": { "synonyms": [{ "terms": ["trousers", "pants"] }] } }'{ "settings": { "synonyms": [{ "terms": ["trousers", "pants"] }] } }{
"success": true,
"data": { "name": "products", "settings": { "synonyms": [{ "terms": ["trousers", "pants"] }] } }
}/search/indexes/:namesearch:manageDelete an index
Removes the index and every document in it. Your own database is untouched, so this costs a reindex rather than the records.
curl -X DELETE https://api.dbrij.com/api/search/indexes/:name \
-H "Authorization: Bearer $DBRIJ_API_KEY"{
"success": true,
"data": { "ok": true }
}A document is any JSON object with an id. Fields are typed as they arrive, and every string is stored twice: once broken into words for searching, once whole for filtering and faceting. That is what lets one field answer both "find shoes when I type shoe" and "category is exactly shoes".
/search/indexes/:name/documentssearch:writeAdd or replace documents
Up to 1,000 documents a call. Sending the same id again replaces that document, so re-running an import is a sync, not a pile of duplicates. CHECK THE STATUS: 201 means every document landed, 207 means some did not, and 422 means none did. Read `indexed` and `failed` either way.
PATH / BODY PARAMETERS
| documents* | object[] | Any JSON you like. Each one needs an id field. |
| primaryKey | string | The field carrying the id, when it is not called `id`. |
curl -X POST https://api.dbrij.com/api/search/indexes/:name/documents \
-H "Authorization: Bearer $DBRIJ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "documents": [{ "id": "sku-1", "title": "Running shoes", "price": 45000, "brand": "Nomad" }] }'{ "documents": [{ "id": "sku-1", "title": "Running shoes", "price": 45000, "brand": "Nomad" }] }{
"success": true,
"data": { "indexed": 1, "failed": 0, "errors": [] }
}/search/indexes/:name/documents/updatesearch:writePartially update documents
Only the fields you send change; everything else on the document stays. Built for volatile fields: a price or stock tick is a two field call, not a resend of the whole record. A document that does not exist is reported per id, never half created.
curl -X POST https://api.dbrij.com/api/search/indexes/:name/documents/update \
-H "Authorization: Bearer $DBRIJ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "documents": [{ "id": "sku-1", "price": 42000, "inStock": false }] }'{ "documents": [{ "id": "sku-1", "price": 42000, "inStock": false }] }{
"success": true,
"data": { "updated": 1, "failed": 0, "errors": [] }
}/search/indexes/:name/documents/deletesearch:writeDelete documents by id or by filter
Send ids to remove named documents, or a filter expression to remove everything that matches it. Retention rules and erasure requests are usually a filter, not a list.
curl -X POST https://api.dbrij.com/api/search/indexes/:name/documents/delete \
-H "Authorization: Bearer $DBRIJ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "filter": "author.id = 'usr_42'" }'{ "filter": "author.id = 'usr_42'" }{
"success": true,
"data": { "deleted": 17 }
}/search/indexes/:name/clearsearch:writeEmpty an index
Removes every document and keeps the index and its settings, which is what a full rebuild wants.
curl -X POST https://api.dbrij.com/api/search/indexes/:name/clear \
-H "Authorization: Bearer $DBRIJ_API_KEY"{
"success": true,
"data": { "ok": true }
}Keeping in sync. Push on write: when a record changes in your database, push that one document. Ids make it an update, so there is nothing to reconcile. A nightly full push is a reasonable safety net on top, and never a substitute.
/search/indexes/:name/querysearch:querySearch an index
The one call your app makes. Safe to call straight from a browser with a search only key.
PATH / BODY PARAMETERS
| q | string | What was typed. Empty returns everything, which is how you browse with facets. |
| filter | string | A filter expression over filterable fields, see Filters and facets. |
| facets | string[] | Fields to count values for, so you can draw refinement lists. |
| sort | string[] | `field:asc` or `field:desc`, applied before relevance. |
| page | number | Defaults to 1. |
| perPage | number | Defaults to 20, up to 100. |
| highlight | boolean | Wraps matched words in `<em>` so you can bold them. |
| typoTolerance | boolean | Overrides the index default for this one query. |
| cursor | string | Deep pagination: pass the cursor from the previous response instead of page. Stable at any depth, which page numbers are not. |
| groupBy | string | Collapse near duplicates: one representative hit per value of this filterable field (the same shirt in six colours becomes one row). |
| aroundLatLng | [lat, lng] | Where the person searching is. Required for geoRanking and the geo ranking stage; without an origin distance cannot influence the order. |
| mode | string | keyword (default), semantic, or hybrid. Semantic and hybrid need the index to have semantic search enabled, see Semantic search. |
| matchFields | string | best (default) needs every word inside one field; cross lets one query’s words land in different fields of the same document. Overrides the index setting for this query. See Relevance. |
| matchingStrategy | string | all (default) returns only documents holding every word; most returns the near misses when nothing holds them all. See Relevance. |
| analytics | boolean | Set false to keep this query out of the analytics report. Use it for smoke tests and diagnostics. The query is still billed: the engine still did the work. |
curl -X POST https://api.dbrij.com/api/search/indexes/:name/query \
-H "Authorization: Bearer $DBRIJ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "q": "runing shoes", "filter": "price < 50000", "facets": ["brand"], "highlight": true }'{ "q": "runing shoes", "filter": "price < 50000", "facets": ["brand"], "highlight": true }{
"success": true,
"data": {
"hits": [{ "id": "sku-1", "document": { "title": "Running shoes", "price": 45000 }, "score": 8.21, "highlights": { "title": ["<em>Running</em> shoes"] } }],
"total": 1,
"page": 1,
"perPage": 20,
"totalPages": 1,
"tookMs": 4,
"query": "runing shoes",
"facets": { "brand": { "Nomad": 1 } },
"cursor": "WzguMjEsInNrdS0xIl0"
}
}/search/indexes/:name/suggestsearch:querySuggest while someone types
Prefix matching over the searchable fields, tuned for a dropdown under the search box: "run" finds "Running shoes" before the word is finished. Metered like a search.
PATH / BODY PARAMETERS
| q* | string | What has been typed so far. |
| limit | number | 1 to 20, defaults to 5. |
curl -X POST https://api.dbrij.com/api/search/indexes/:name/suggest \
-H "Authorization: Bearer $DBRIJ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "q": "run", "limit": 5 }'{ "q": "run", "limit": 5 }{
"success": true,
"data": { "suggestions": [{ "id": "sku-1", "document": { "title": "Running shoes" }, "score": 4.1 }], "tookMs": 2 }
}/search/multi-searchsearch:querySearch several indexes at once
People search for things, not kinds: one call runs up to 10 queries across your indexes and returns each result set labelled by index. Each query in the batch is metered as a search.
curl -X POST https://api.dbrij.com/api/search/multi-search \
-H "Authorization: Bearer $DBRIJ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "queries": [{ "index": "products", "q": "invoice" }, { "index": "articles", "q": "invoice" }] }'{ "queries": [{ "index": "products", "q": "invoice" }, { "index": "articles", "q": "invoice" }] }{
"success": true,
"data": { "results": [{ "index": "products", "hits": [], "total": 0 }, { "index": "articles", "hits": [{ "id": "a1" }], "total": 1 }] }
}Paging deep. Page numbers are fine for a results page; for export or infinite scroll use the cursor that comes back on every response and pass it to the next call. It walks the whole index in order without the drift and cost page offsets have at depth.
Typos are forgiven by default. One wrong letter on short words, two on longer ones. Turn it off per query with typoTolerance: false when you are searching identifiers, where "sku-1234" and "sku-1235" are different things rather than a near miss.
Highlights come back per field as an array of snippets with matches wrapped in <em> (a long field can produce several snippets). Render them as text unless you trust every document in the index, since the surrounding content is yours, not ours.
A filter narrows what can match, before relevance is considered. It may only name fields listed in the index's filterableFields: anything else is refused by name, which keeps a filter string that came from a URL from reaching further than you meant it to.
FILTER GRAMMAR
| = != | any | Exact match. Strings are compared whole, so `brand = "Nomad"` does not match "Nomad Sport". |
| > >= < <= | number | Ranges over numeric fields, e.g. `price >= 10000`. |
| IN [a, b] | any | Any of the listed values: `category IN ["shoes", "boots"]`. |
| AND OR | AND binds tighter than OR. Use brackets when you mean otherwise. | |
| ( ) | Grouping, e.g. `(brand = "Nomad" OR brand = "Ardent") AND price < 50000`. | |
| EXISTS NOT EXISTS | any | Whether the field is present on the document at all: `location EXISTS`. A document missing the field is not the same as one where it is empty, and a geo filter silently excludes both. Works on any field in filterableFields, and on a geoFields field without listing it as filterable as well. |
| IS NULL IS NOT NULL | any | The same question in the other spelling. `zone IS NULL` is `zone NOT EXISTS`. |
| _geoRadius(field, lat, lng, m) | geo | Everything within m meters of the point, on a field declared in geoFields: `_geoRadius(location, 6.45, 3.39, 5000)`. |
Length. A filter may be up to 20,000 characters, which holds a list of roughly a thousand values. That is deliberate room for the common case of narrowing to one neighbourhood or one tenant with IN [...]. Splitting one logical search across several queries to get under a limit costs you metered searches and gives you scores from different queries, which are not comparable.
Catalogues that are still filling in. A geo filter excludes documents with no coordinates yet, which is rarely what you want while you are still importing. Say so explicitly: _geoRadius(location, 6.45, 3.39, 5000) OR location NOT EXISTS.
Dates. Send dates as ISO 8601 strings (2026-08-24T10:00:00Z) and they are stored as real times, so ranges do what you mean: publishedAt >= "2026-07-01" is the last month, not an alphabet comparison.
Arrays and nested objects. An array field matches when any element matches, so tags = "sale" finds a document whose tags are ["new", "sale"], and faceting on it counts every element. Reach into nested objects with dots: author.country = "NG". Declare the dotted path itself in filterableFields.
Near me. Declare the field in geoFields, store it as { "lat": 6.45, "lon": 3.39 }, then filter with _geoRadius and sort by distance with "sort": ["_geoDistance(location, 6.45, 3.39):asc"]. To blend distance into relevance instead of sorting by it outright, send the searcher's position as aroundLatLng on the query and set geoRanking on the index, see Relevance.
{
"q": "shoes",
"filter": "inStock = true AND price < 50000",
"facets": ["brand", "category"],
"sort": ["price:asc"],
"perPage": 24
}Facets come back as counts per value, computed over everything the filter allowed. That is what lets you show "Nomad (12)" beside a checkbox and have the number be true.
Settings live on the index. Change them in the dashboard or through the settings endpoint.
SETTINGS
| searchableFields | string[] | What a query looks at, most important first. Empty searches every text field, which is the right start for most people. |
| filterableFields | string[] | The only fields a filter may name. |
| sortableFields | string[] | The only fields a sort may name. |
| retrievableFields | string[] | What comes back in a hit. Empty returns the whole document. |
| typoTolerance | boolean | Forgive typos. On by default. Not available together with matchFields: "cross". |
| matchFields | string | best (default) needs every word of a query inside ONE field; cross lets them land in different fields of the same document, while still ranking by field weights. See Matching below. |
| matchingStrategy | string | all (default) returns only documents holding every word; most keeps short queries strict and lets longer ones through on a strong majority. |
| splitLetterDigit | boolean | Split where letters meet digits, so "item7" and "item 7" find each other and the number is required rather than optional. An index setting only, never per query: it changes how documents are stored, so it rebuilds the index from the documents it already holds. Nothing needs re-pushing. |
| matchJoinedWords | boolean | Also index adjacent words joined, so "amalasky" finds "Amala Sky". Phone keyboards drop spaces constantly and typo tolerance does not cover a missing one. An index setting only, never per query, because it lives in the stored index. Rebuilds the index; costs index size, not query time. |
| stopWords | string[] | Words ignored in a query. Leave empty unless you have a reason: stop words hurt phrase searches. |
| synonyms | group[] | Two shapes: { terms: ["sofa", "couch"] } makes every term match the others, and { from: ["panadol"], to: ["paracetamol"] } is one way, so the query "panadol" finds paracetamol without the reverse. Mine the "found nothing" list for candidates. |
| language | string | Used for stemming, so "running" finds "run". Defaults to english. |
| geoFields | string[] | Fields holding a location as { lat, lon }. Declared here so the engine stores them as points; changing this rebuilds the index. |
| customRanking | string[] | Business signals as tiebreakers after text relevance, e.g. ["popularity:desc", "createdAt:desc"]. When two hits match equally well, these decide who wins. |
| geoRanking | object | Blend distance into the score rather than filtering or sorting by it. Needs aroundLatLng on the query, otherwise it simply does not apply. |
| rankingChain | string[] | What decides the order and in which order it decides: stages of relevance, geo, custom. Defaults to relevance then custom. Putting geo in the chain is the blunt tool where nearest wins outright; geoRanking is the fine one, use one at a time. |
| facetNormalisation | object | Fold the many spellings of one facet value into one ("XL", "xl", "X Large" count together). Changing it rebuilds the index. |
| facetAliases | object | Display labels for folded facet values, read at query time with no rebuild. |
| semantic | object | Match meaning as well as words: { enabled, model, fields, weight }. Turning it on or changing the model rebuilds the index. See Semantic search. |
| fusion | string | How keyword and semantic result lists become one in hybrid mode: rrf (default, rank based, nothing to calibrate) or linear. Query time, no rebuild. |
Matching: where the words have to be. By default a document matches when some single searchable field contains every word. That suits prose and is wrong for records assembled from parts. If your items carry itemName, storeName and zone, then "jollof rice ikeja" returns nothing when the dish is in the name and the place is in the zone, even though the document plainly holds every word. Set matchFields: "cross" and the searchable fields are pooled for matching while your weights still decide the order. Thing plus place is how people search a catalogue, so most marketplaces want this on.
Matching: how many words have to land. matchingStrategy: "all" is precise and returns nothing when a long query has no perfect document. "most" keeps a two word query strict and lets a five word one through on four, so a stray word returns the near misses instead of an empty page. Set either on the index or per query.
One caveat. Typo tolerance and matchFields: "cross" cannot both apply to one query: the engine will not fuzzy match across pooled fields. Cross wins and typo tolerance is skipped for that query, rather than the query being refused.
Field weights. Searchable fields take a weight with a caret: "title^3" counts a title match three times as hard as a body match. Order plus weights is usually all the tuning a catalogue needs before customRanking earns its keep.
The workflow that actually improves search. Read the "found nothing" list in Analytics. Each row is somebody who wanted something and did not get it. Most of them are one synonym away, or one field that should have been searchable.
Synonym mining does that reading for you. The dashboard's Relevance page suggests synonyms mined from your own missed queries (each candidate shows how many searches it would have rescued), and accepting one applies it to the index in a click. Start there before writing synonyms by hand.
Keyword search matches what somebody typed. Semantic search matches what they meant, which is the difference between "something for a headache" finding nothing and finding paracetamol. Neither is better: keyword is exact and cheap and gets product codes right; semantic is forgiving and gets intent right. Hybrid runs both and fuses the two lists, and is what most search boxes want.
Turn it on per index, in the dashboard's index settings or through the settings endpoint. Enabling it (or changing the model) rebuilds the index in the background while queries keep serving on the old rules. It is off by default and needs a paid plan, because a vector index is resident memory.
curl -X PUT https://api.dbrij.com/api/search/indexes/products/settings \
-H "Authorization: Bearer $DBRIJ_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "settings": { "semantic": { "enabled": true, "model": "voyage-4-lite", "fields": [], "weight": 0.5 } } }'SEMANTIC SETTINGS
| enabled | boolean | Off by default. Turning it on embeds your documents and rebuilds the index. |
| model | string | voyage-4-lite (default: good quality, smallest memory), voyage-4 (better on specialised vocabulary, four times the memory), or text-embedding-3-small (for teams that prefer OpenAI as the vendor). |
| fields | string[] | Fields whose text is embedded. Empty means the searchable fields, which is almost always what you want. |
| weight | number | How much of the ranking meaning gets, 0 to 1. At 0 semantic contributes nothing; at 1 keyword does. 0.5 is the sensible start. |
Choosing per query. Send mode on the query call: "keyword" (default), "semantic", or "hybrid". A common pattern is hybrid for the main search box and keyword for identifier lookups where "sku-1234" must never fuzzy match "sku-1235".
{ "q": "something for a headache", "mode": "hybrid", "perPage": 10 }Fusion. In hybrid mode the keyword list and the semantic list become one ranking. The default, rrf (reciprocal rank fusion), only reads the position a document reached in each list, so there is nothing to calibrate: a hit near the top of either list rises, a hit near the top of both wins. linear adds the scores instead and is offered for anyone who has measured their corpus and disagrees.
Semantic and hybrid queries on an index that has semantic disabled, or on the Free plan, return a 400 that says so. Everything else about the call (filters, facets, scoped tokens, metering) works exactly as in keyword mode.
Every query is logged with whether it found anything. The "found nothing" list is the single most actionable thing in search: each row is somebody who wanted something, did not get it, and might have left over it.
/search/analytics?days=30search:manageQuery analytics
The same numbers the dashboard shows, as an endpoint, so the improvement loop can be automated: alert when noResultSearches jumps, or feed missedQueries into synonym generation.
curl -X GET https://api.dbrij.com/api/search/analytics?days=30 \
-H "Authorization: Bearer $DBRIJ_API_KEY"{
"success": true,
"data": {
"totalSearches": 48210,
"noResultSearches": 1861,
"avgTookMs": 6,
"topQueries": [{ "query": "running shoes", "count": 1204, "noResultRate": 0, "avgTookMs": 4 }],
"missedQueries": [{ "query": "panadol", "count": 320, "noResultRate": 1, "avgTookMs": 3 }]
}
}This endpoint needs search:manage, so call it with an admin key from your server. A search only key gets a 403 here by design: analytics describe everything your users search for, which is not something a key that ships in a browser should be able to read.
Retention follows your plan (7 to 90 days). Suggestions are not logged: five keystrokes are one intent, and logging each would drown the signal this list exists for.
Keep your own tests out of it. Send "analytics": false on smoke tests, health checks and anything you run from a script. A diagnostic is not a person looking for something, and the "found nothing" list is only worth reading while every row on it is real. The query is still billed either way: the engine still did the work.
Documents are a hard ceiling and searches are metered. A document costs memory for as long as it exists, while a query costs milliseconds, so going past your document limit is refused rather than billed, and searches past the allowance bill per thousand at renewal. The free plan has no overage at all: it stops serving instead of running up a bill you did not agree to.
| Plan | Documents included | Up to | Each extra 1,000 a month | Searches a month | Indexes | Requests a minute | Semantic |
|---|---|---|---|---|---|---|---|
| Free | 10,000 | 10,000 | Hard ceiling | 3,000 | 2 | 60 | No |
| Starter | 100,000 | 300,000 | ₦130 | 500,000 | 5 | 600 | Yes |
| Growth | 300,000 | 1,000,000 | ₦130 | 5,000,000 | 20 | 1,200 | Yes |
| Scale | 900,000 | 10,000,000 | ₦130 | 50,000,000 | 100 | 3,000 | Yes |
A document counts once for every 2 KB it takes in the index, so a long article can count as several. A typical product or listing counts as one.
ERRORS WORTH HANDLING
| 207 | partial write | Some documents were indexed and some were not. `indexed` and `failed` say how many, and `errors` says which and why. A write is the one place where "the request worked" and "your data is in" are different claims. |
| 400 | bad request | A filter naming a field the index did not declare filterable, a sort on an unsortable field, or a malformed expression. The message says which field. Also a semantic or hybrid query on the Free plan or on an index with semantic disabled. |
| 401 | unauthorized | Missing, revoked or wrong key. |
| 403 | forbidden | The key is real but lacks the scope, e.g. a search only key trying to write documents. |
| 402 | plan limit | The workspace holds more documents or indexes than its plan allows and the week of grace has passed. The message names what is over and the cheapest plan that holds it. Move up, or remove what is over; adding more was already refused. Code plan_limit_exceeded. |
| 404 | not found | No index by that name in this workspace. |
| 422 | nothing indexed | The request was valid but not one document was accepted. `errors` carries the engine's reason per document, including what caused it. Treat this as a failed sync, never a successful one. |
| 429 | rate limited | Past your plan's requests a minute, or on Free, past the day's 100 searches (code daily_search_cap, with resetsAt). `Retry-After` says how long to wait, and every response carries `X-RateLimit-Remaining`. |
| 503 | unavailable | The search engine is not reachable. Existing data is fine; retry shortly. |
Bulk ingestion. Up to 1,000 documents per call; chunk bigger loads and run chunks in sequence. Writes count against your requests a minute like everything else, and the response tells you per document what was indexed and what failed, so a crashed import is safe to re-run from the top.
A plan is what you hold, not only what you add. The document and index ceilings apply to what the workspace is holding right now, not just to the next push. Move to a smaller plan while holding more than it allows and you get a week: everything keeps working, the owner gets an email, the billing page says what is over and which plan fits. After the week, queries answer 402 until the workspace is back inside its plan, either by moving up or by removing what is over. Free plans also cap searches at 100 a day.
Never trust the status code alone on a write. A write has three outcomes and each has its own code: 201 every document landed, 207 some did not, 422 none did. Read indexed and failed as well, and only record a sync as complete when failed is zero. A loop that checks res.ok and moves on will happily report a finished import over an empty index.
Handling 429 in a search box. Debounce by 100ms or so before searching on keystroke, and on a 429 keep the last good results on screen rather than clearing them. An empty list reads as "nothing found", which is a worse lie than a slightly stale one.
Dbrij is Nigeria’s company operating system: the first Nigerian built platform to put team chat, video meetings, email, HR, tax compliant payroll, customer support, marketing, analytics and cloud hosting in one product on one subscription.