Dbrij Search Documentation

Everything you need to implement search with Dbrij Search: indexes, documents, querying, filters, facets, relevance settings and limits.

Dbrij Search

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.

Base URLhttps://api.dbrij.com/api

Every 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.

Quickstart

Three calls from nothing to a working search.

1. Create an index
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"] } }'
2. Push your documents
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:

2b. Trust the body, in whatever language you sync from
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');
}
3. Search from your front end
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.

Keys and safety

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 onlysearch:queryReads indexes. Cannot write a document, change relevance or delete anything. Ship it in your JavaScript, your mobile app, anywhere your users can read it.
adminsearch:write, search:manageWrites 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.

Mint a scoped token on your server (Node)
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.

Frontend kit

Search in a browser without writing the plumbing: a script tag for any website, widgets, React components and a typed client, all with click tracking built in, so learned ranking, taste, experiments and recommendations learn from day one. Use a search only key or a scoped token, never an admin key.

Any website: one script tag
<div id="search"></div>
<script src="https://search.dbrij.com/search.js" async
  data-key="dbrij_live_your_search_key"
  data-index="products"
  data-target="#search"
  data-facets="brand,category"></script>

With data-target the whole experience draws inside that element: the box, refinements, results and pages, starting from ?q= in the page address. Give data-input with your own search box instead and search opens in a panel over the page as people type; pressing / opens it too. Add data-answers="true" for an answer above the results, data-understand="true" to read plain language into filters, and data-theme="dark" or auto for dark colours.

Or install it
npm install @dbrij/search
Widgets
import { SearchClient, mountSearch, mountAutocomplete } from '@dbrij/search';

const client = new SearchClient({ key: 'dbrij_live_your_search_key' });

mountSearch('#search', client, { index: 'products', facets: ['brand'], answers: true });

mountAutocomplete('#site-search', client, {
  index: 'products',
  onSelect: ({ query, objectId }) => {
    location.href = objectId ? `/products/${objectId}` : `/search?q=${encodeURIComponent(query ?? '')}`;
  },
});
React
import { SearchProvider, SearchBox, RefinementList, Hits, Pagination } from '@dbrij/search/react';

<SearchProvider client={client} index="products" facets={['brand']}>
  <SearchBox />
  <RefinementList field="brand" label="Brand" />
  <Hits render={(hit, track) => <a href={`/products/${hit.id}`} onClick={track}>{hit.document.title}</a>} />
  <Pagination />
</SearchProvider>

Clicks and conversions track themselves. The widgets and components send each click with the search that produced it. A purchase later on, even on another page, is credited to that search for an hour:

A conversion, with no queryId to carry around
client.insights.converted({ index: 'products', objectIds: ['sku-1'], kind: 'purchase', value: 4500, currency: 'NGN' });

Drawing results yourself? Put data-dbrij-object-id, data-dbrij-position, data-dbrij-index and data-dbrij-query-id on each one and call trackClicks(container, client) once. Events are batched, sent within a second, flushed when the page closes, and never throw into your page.

One person, one token. The client gives each visitor an anonymous userToken and keeps it, so typing sessions, personal taste and experiments see one person. When they sign in, call client.setAuthenticatedUserToken(yourUserId) and their earlier activity joins their profile.

Safe with any record. Record text is always escaped, highlights keep only their <mark> tags, and links that are not web addresses are dropped, so a record cannot run script on your page.

Indexes

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.

POST/search/indexessearch:manage

Create 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*string2 to 64 characters: lowercase letters, numbers, dashes and underscores. This is what every path uses.
settingsobjectOptional relevance settings, see Relevance. Anything omitted keeps its default.
Request
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"] } }'
Request body
{ "name": "products", "settings": { "searchableFields": ["title", "description"], "filterableFields": ["category", "price"] } }
Response 200
{
  "success": true,
  "data": { "id": "3f…", "name": "products", "documentCount": 0, "status": "ready" }
}
GET/search/indexessearch:manage

List indexes

Every index in the workspace with its document count, size and settings.

Request
curl -X GET https://api.dbrij.com/api/search/indexes \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "success": true,
  "data": [{ "name": "products", "documentCount": 1240, "sizeBytes": 918273, "status": "ready" }]
}
PUT/search/indexes/:name/settingssearch:manage

Change 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.

Request
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"] }] } }'
Request body
{ "settings": { "synonyms": [{ "terms": ["trousers", "pants"] }] } }
Response 200
{
  "success": true,
  "data": { "name": "products", "settings": { "synonyms": [{ "terms": ["trousers", "pants"] }] } }
}
DELETE/search/indexes/:namesearch:manage

Delete an index

Removes the index and every document in it. Your own database is untouched, so this costs a reindex rather than the records.

Request
curl -X DELETE https://api.dbrij.com/api/search/indexes/:name \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "success": true,
  "data": { "ok": true }
}

Documents

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".

POST/search/indexes/:name/documentssearch:write

Add 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.
primaryKeystringThe field carrying the id, when it is not called `id`.
Request
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" }] }'
Request body
{ "documents": [{ "id": "sku-1", "title": "Running shoes", "price": 45000, "brand": "Nomad" }] }
Response 200
{
  "success": true,
  "data": { "indexed": 1, "failed": 0, "errors": [] }
}
POST/search/indexes/:name/documents/updatesearch:write

Partially 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.

Request
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 }] }'
Request body
{ "documents": [{ "id": "sku-1", "price": 42000, "inStock": false }] }
Response 200
{
  "success": true,
  "data": { "updated": 1, "failed": 0, "errors": [] }
}
POST/search/indexes/:name/documents/deletesearch:write

Delete 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.

Request
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'" }'
Request body
{ "filter": "author.id = 'usr_42'" }
Response 200
{
  "success": true,
  "data": { "deleted": 17 }
}
POST/search/indexes/:name/clearsearch:write

Empty an index

Removes every document and keeps the index and its settings, which is what a full rebuild wants.

Request
curl -X POST https://api.dbrij.com/api/search/indexes/:name/clear \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "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.

Filters and facets

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

= !=anyExact match. Strings are compared whole, so `brand = "Nomad"` does not match "Nomad Sport".
> >= < <=numberRanges over numeric fields, e.g. `price >= 10000`.
IN [a, b]anyAny of the listed values: `category IN ["shoes", "boots"]`.
AND ORAND binds tighter than OR. Use brackets when you mean otherwise.
( )Grouping, e.g. `(brand = "Nomad" OR brand = "Ardent") AND price < 50000`.
EXISTS NOT EXISTSanyWhether 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 NULLanyThe same question in the other spelling. `zone IS NULL` is `zone NOT EXISTS`.
_geoRadius(field, lat, lng, m)geoEverything 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.

A faceted product page in one call
{
  "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.

Picks go in refinements, not the filter. Put what a shopper ticked in refinements. The results narrow exactly as they would with the same picks in the filter, but a refined field is counted as if its own picks were not made, so after choosing Nomad the brand list still shows Ardent with its count, ready to add. Every other filter and pick still applies to that count. It is one search, billed once.

Two brands picked, sizes still counted under both
{
  "q": "shoes",
  "filter": "inStock = true",
  "refinements": { "brand": ["Nomad", "Ardent"], "size": [42] },
  "facets": ["brand", "size"]
}

Relevance

Settings live on the index. Change them in the dashboard or through the settings endpoint.

SETTINGS

searchableFieldsstring[]What a query looks at, most important first. Empty searches every text field, which is the right start for most people.
filterableFieldsstring[]The only fields a filter may name.
sortableFieldsstring[]The only fields a sort may name.
retrievableFieldsstring[]What comes back in a hit. Empty returns the whole document.
typoTolerancebooleanForgive typos. On by default. Not available together with matchFields: "cross".
matchFieldsstringbest (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.
matchingStrategystringall (default) returns only documents holding every word; most keeps short queries strict and lets longer ones through on a strong majority.
splitLetterDigitbooleanSplit 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.
matchJoinedWordsbooleanAlso 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.
stopWordsstring[]Words ignored in a query. Leave empty unless you have a reason: stop words hurt phrase searches.
synonymsgroup[]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.
languagestringUsed for stemming, so "running" finds "run". Defaults to english.
geoFieldsstring[]Fields holding a location as { lat, lon }. Declared here so the engine stores them as points; changing this rebuilds the index.
customRankingstring[]Business signals as tiebreakers after text relevance, e.g. ["popularity:desc", "createdAt:desc"]. When two hits match equally well, these decide who wins.
geoRankingobjectBlend distance into the score rather than filtering or sorting by it. Needs aroundLatLng on the query, otherwise it simply does not apply.
rankingChainstring[]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.
facetNormalisationobjectFold the many spellings of one facet value into one ("XL", "xl", "X Large" count together). Changing it rebuilds the index.
facetAliasesobjectDisplay labels for folded facet values, read at query time with no rebuild.
semanticobjectMatch meaning as well as words: { enabled, model, fields, weight }. Turning it on or changing the model rebuilds the index. See Semantic search.
fusionstringHow 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.

Semantic search

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.

Enable it on an index
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

enabledbooleanOff by default. Turning it on embeds your documents and rebuilds the index.
modelstringvoyage-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 would rather not add a second vendor).
fieldsstring[]Fields whose text is embedded. Empty means the searchable fields, which is almost always what you want.
weightnumberHow 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".

A hybrid query
{ "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.

Events

Every search returns a queryId. When the person clicks a result, adds it to a cart or buys it, send an event with that queryId. Events are what learned ranking, personal taste, categories and experiments learn from, and what engagement analytics count.

POST/search/eventssearch:query

Send clicks, conversions and views

Up to 1,000 events per call, from the browser with a search only key or from your server. Valid events are kept and invalid ones are named with their reason in the response; one bad event never sinks the batch.

PATH / BODY PARAMETERS

events[].type*stringclick, conversion or view.
events[].name*stringYour label, 1 to 64 characters, like "Product clicked" or "Purchased".
events[].index*stringThe index the records live in.
events[].userToken*stringYour pseudonymous id for the person: 1 to 129 of letters, digits and _ = / + . and -. Never an email or a phone number.
events[].authUserTokenstringThe same person once signed in, so an anonymous session joins their profile.
events[].queryIdstringThe queryId the search returned. Required for clicks to teach learned ranking and to count in experiments.
events[].objectIds*string[]1 to 50 record ids.
events[].positionsnumber[]Optional. With a queryId the positions are read from the search itself, which is more reliable than anything a page can send.
events[].kindstringFor conversions: addToCart or purchase.
events[].valuenumberRevenue for a purchase, with currency (NGN, USD).
events[].timestampnumberMilliseconds since 1970, up to 30 days old. Defaults to when it arrived.
Request
curl -X POST https://api.dbrij.com/api/search/events \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "events": [
  { "type": "click", "name": "Product clicked", "index": "products", "userToken": "anon-71c2", "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77", "objectIds": ["sku-1"] },
  { "type": "conversion", "name": "Purchased", "index": "products", "userToken": "anon-71c2", "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77", "objectIds": ["sku-1"], "kind": "purchase", "value": 45000, "currency": "NGN" }
] }'
Request body
{ "events": [
  { "type": "click", "name": "Product clicked", "index": "products", "userToken": "anon-71c2", "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77", "objectIds": ["sku-1"] },
  { "type": "conversion", "name": "Purchased", "index": "products", "userToken": "anon-71c2", "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77", "objectIds": ["sku-1"], "kind": "purchase", "value": 45000, "currency": "NGN" }
] }
Response 200
{
  "success": true,
  "data": { "accepted": 2, "rejected": [] }
}

Checked against the search. A click that names a queryId must be a record that search actually showed, or it is refused with that reason, so a buggy page cannot quietly train your ranking wrong. Its position is taken from the search itself.

Private by design. The userToken is yours to choose and should never be a name, an email or a phone number. Each person counts once per record per search when ranking is learned, so one enthusiast cannot move a result. Erase a person at any time with the profile endpoint under Learned ranking and taste.

GET/search/events/health?days=7search:manage

Events health

Events accepted and refused, the reasons for refusals, and the share of clicks that came with a queryId. A broken integration shows here before it shows anywhere else.

Request
curl -X GET https://api.dbrij.com/api/search/events/health?days=7 \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "success": true,
  "data": { "accepted": 18240, "rejected": 12, "attributedClickShare": 0.97, "reasons": [{ "reason": "a clicked record was not in that search’s results.", "count": 12 }], "lastEventAt": "2026-10-27T09:14:02Z" }
}
GET/search/analytics/engagement?days=30search:manage

Engagement analytics

Click through and conversion rates, the average click position, revenue from search, the most clicked searches, and the searches that found results nobody clicked.

Request
curl -X GET https://api.dbrij.com/api/search/analytics/engagement?days=30 \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "success": true,
  "data": { "trackedSearches": 48210, "clickThroughRate": 0.41, "conversionRate": 0.06, "averageClickPosition": 2.3, "noClickQueries": [{ "query": "charger", "searches": 310, "withResults": 310 }], "topClickedQueries": [{ "query": "running shoes", "searches": 1204, "clicks": 802, "ctr": 0.58 }], "revenue": [{ "currency": "NGN", "total": 9120000 }] }
}

Learned ranking and taste

Four settings on an index, each read when a search runs, so turning one on or changing it never rebuilds anything. All of them learn from events and update every hour.

reRanking { enabled, windowDays: 7 | 30 | 90, maxMove: 1 to 20, promoteFilter }. Results people choose for a search rise and ones nobody picks sink. A click at position 8 counts for more than a click at position 1 (people click the top because it is the top), small numbers are smoothed toward the search’s average instead of trusted, nothing moves more than maxMove places, and only records matching promoteFilter may rise. It only reorders what the search found; it never adds a record. Ask with explain: true to see from and to on each hit that moved.

personalization { enabled, facets: [{ field, weight }], impact: 0 to 100, eventWeights }. Each person’s results lean toward the values they choose (a brand, a category), from the events carrying their userToken. At impact 100 a favourite gets at most double the score. Taste halves every 30 days without new events and is forgotten after 90.

categorization { enabled, field, boostAt, filterAt }. What a search is about, learned from the category of what people chose after it, returned as categories with a confidence. At boostAt the category is boosted; at filterAt (optional) results are filtered to it. A person’s own filter on that field always wins. A search needs choices spread over at least three records before it is categorized.

suggestions { banned, minSearches, minLetters } keep junk out of query suggestions.

POST/search/indexes/:name/query-suggestionssearch:query

Query suggestions

What people actually search that finds results, matching what is being typed: "run" offers "running shoes" because people search it. Rebuilt every hour from the last 30 days. Not metered as a search.

PATH / BODY PARAMETERS

qstringWhat has been typed. Empty returns the most searched.
limitnumber1 to 20, defaults to 8.
Request
curl -X POST https://api.dbrij.com/api/search/indexes/:name/query-suggestions \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "q": "run" }'
Request body
{ "q": "run" }
Response 200
{
  "success": true,
  "data": [{ "query": "running shoes", "searches": 1204 }, { "query": "running watch", "searches": 233 }]
}
GET/search/indexes/:name/profiles/:userTokensearch:manage

What personalization learned about a person

Their affinity for each value of each learned field, 0 to 1.

Request
curl -X GET https://api.dbrij.com/api/search/indexes/:name/profiles/:userToken \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "success": true,
  "data": { "userToken": "anon-71c2", "lastEventAt": "2026-10-27T09:14:02Z", "affinities": { "brand": { "Nomad": 1, "Trek": 0.42 } } }
}
DELETE/search/profiles/:userTokensearch:manage

Erase a person

Their taste is deleted and their events and searches stop carrying their token, across every index. Use it when someone asks to be forgotten.

Request
curl -X DELETE https://api.dbrij.com/api/search/profiles/:userToken \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "success": true,
  "data": { "erased": true }
}

Merchandising rules

Rules steer what a search shows: pin a record to a place, hide one, boost or bury by a filter, add a filter, rewrite the query, or hand your app a banner. Every response names the rules that fired in appliedRules, so nothing about the order is a mystery.

Which rule wins. The most precise: an exact match beats starts with, which beats ends with, which beats contains; then the longer pattern; then a rule aimed at a context; then the most recently changed. Only the best rewrite is used. Two pins asking for one place: the better rule keeps it and the other moves down. A record both pinned and hidden is hidden. Pins respect the search’s filters, including a scoped key’s.

The order of work in a search: rules (rewrite, filters, boosts, hides), then learned ranking, then pins.

POST/search/indexes/:name/rulessearch:manage

Add a rule

A rule names the searches it is for and what to do for them. Any one condition matching is enough. Takes effect within 30 seconds.

PATH / BODY PARAMETERS

name*stringUp to 120 characters.
conditions*object[]1 to 25 of { pattern, anchoring: is | startsWith | endsWith | contains, context? }. Matching is on whole words, case and accents folded. An empty pattern with is matches browsing (no query).
consequence.pinsobject[]Up to 50 of { id, position }: the record at that place, counting from 1 across pages.
consequence.hidestring[]Up to 200 record ids never shown for these searches.
consequence.boostsobject[]Up to 10 of { filter, strength: slight | moderate | strong, direction: boost | bury }. Boosts multiply relevance, so a strong boost on a poor match still loses to an excellent one.
consequence.filterstringA filter added to these searches, ANDed with the caller’s.
consequence.replaceQuerystringSearch for this instead.
consequence.removeWordsstring[]Words dropped from the query ("cheap" in "cheap phones").
consequence.userDataobjectUp to 8 KB returned in userData for your app to draw: a banner, a redirect, a message.
validFrom, validUntilstringISO times. Outside its window a rule does nothing, so promotions end on their own.
Request
curl -X POST https://api.dbrij.com/api/search/indexes/:name/rules \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Shoe week", "conditions": [{ "pattern": "shoes", "anchoring": "contains" }], "consequence": { "pins": [{ "id": "sku-9", "position": 1 }], "boosts": [{ "filter": "brand = \"Nomad\"", "strength": "moderate", "direction": "boost" }], "userData": { "banner": "Shoe week: 20% off" } }, "validUntil": "2026-11-30T23:59:59Z" }'
Request body
{ "name": "Shoe week", "conditions": [{ "pattern": "shoes", "anchoring": "contains" }], "consequence": { "pins": [{ "id": "sku-9", "position": 1 }], "boosts": [{ "filter": "brand = \"Nomad\"", "strength": "moderate", "direction": "boost" }], "userData": { "banner": "Shoe week: 20% off" } }, "validUntil": "2026-11-30T23:59:59Z" }
Response 200
{
  "success": true,
  "data": { "id": "r-1", "index": "products", "name": "Shoe week", "enabled": true, "conditions": [ … ], "consequence": { … } }
}
GET/search/indexes/:name/rulessearch:manage

List rules

Every rule on the index, newest change first.

Request
curl -X GET https://api.dbrij.com/api/search/indexes/:name/rules \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "success": true,
  "data": [{ "id": "r-1", "name": "Shoe week", "enabled": true }]
}
PUT/search/indexes/:name/rules/:ruleIdsearch:manage

Change a rule

The whole rule, as on create.

Request
curl -X PUT https://api.dbrij.com/api/search/indexes/:name/rules/:ruleId \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
DELETE/search/indexes/:name/rules/:ruleIdsearch:manage

Remove a rule

Gone within 30 seconds.

Request
curl -X DELETE https://api.dbrij.com/api/search/indexes/:name/rules/:ruleId \
  -H "Authorization: Bearer $DBRIJ_API_KEY"

A/B experiments

Try a change on part of your real traffic before everyone gets it. Each search that carries a userToken stays in one variant for that person; searches without one are assigned one by one. The response says which variant ran in abTest, and the clicks and conversions sent with its queryId decide the result.

Two answers are given side by side: the probability a variant is really better (Bayesian, which is what a person deciding wants to know) and a two sided p value (for anyone who has to report one), plus the searches each variant still needs. Stop early on a strong verdict if you like; stopping on a weak one is how experiments lie.

POST/search/ab-testssearch:manage

Start an experiment

Two to four variants share an index’s traffic. A variant changes only what is read at search time, so nothing is copied or rebuilt and there is no extra cost per variant. One experiment runs per index at a time.

PATH / BODY PARAMETERS

name, index*stringWhat it is called and where it runs.
metric*stringclickThroughRate, conversionRate, noResultRate or revenuePerSearch.
variants*object[]{ name, trafficPercent, overrides }. Shares add up to 100. overrides may set searchableFields, customRanking, rankingChain, typoTolerance, matchFields, matchingStrategy, mode, and turn reRanking, personalization, rules or categorization on or off.
days*number1 to 90. It finishes on its own.
minimumEffectnumberThe smallest relative change worth detecting (0.05 is 5 percent), for the sample size.
Request
curl -X POST https://api.dbrij.com/api/search/ab-tests \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Learned ranking on", "index": "products", "metric": "clickThroughRate", "days": 14, "variants": [{ "name": "A", "trafficPercent": 50, "overrides": {} }, { "name": "B", "trafficPercent": 50, "overrides": { "reRanking": true } }] }'
Request body
{ "name": "Learned ranking on", "index": "products", "metric": "clickThroughRate", "days": 14, "variants": [{ "name": "A", "trafficPercent": 50, "overrides": {} }, { "name": "B", "trafficPercent": 50, "overrides": { "reRanking": true } }] }
GET/search/ab-testssearch:manage

Experiments and their results

Per variant: searches, click through, conversion, empty searches and revenue per search, then against the first variant the probability of being really better, a two sided p value and the change. samplesNeeded says how many searches each variant needs before the result can be trusted, and verdict says it in a sentence.

Request
curl -X GET https://api.dbrij.com/api/search/ab-tests \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "success": true,
  "data": [{ "name": "Learned ranking on", "status": "running", "results": [{ "name": "A", "searches": 9120, "clickThroughRate": 0.40 }, { "name": "B", "searches": 9184, "clickThroughRate": 0.44, "probabilityBetter": 0.99, "pValue": 0.0003, "lift": 0.10 }], "samplesNeeded": 8900, "verdict": "B beats A on click through rate (up 10%), with a 99% chance of being really better." }]
}
POST/search/ab-tests/:testId/stopsearch:manage

Stop an experiment

Traffic goes back to the index as set up; the results stay.

Request
curl -X POST https://api.dbrij.com/api/search/ab-tests/:testId/stop \
  -H "Authorization: Bearer $DBRIJ_API_KEY"

Website crawler

Point a crawler at a site and it keeps an index of its pages, daily, weekly or when you run it. It identifies as DbrijSearchBot, obeys robots.txt (a group naming it wins over *), reads at most two pages at a time with a pause between them, and skips pages marked noindex.

Safe recrawls. Pages the last crawl made that this one no longer finds are removed, unless that would remove more than a tenth of them: a site that is down, or a robots.txt that started refusing everything, must not empty your index. That crawl stops and says so instead.

Pages are read as your server sends them, without running JavaScript. A page that only appears after scripts run should be pushed with the documents API instead. Each page is one record and counts toward your plan.

POST/search/crawlerssearch:manage

Add a website crawler

Turns a website into records: one per page with id, url, title, description, headings, content, lang and crawledAt. Make title, description, headings and content searchable on the index.

PATH / BODY PARAMETERS

name, index*stringWhat it is called and the index it fills.
startUrls*string[]Up to 20 pages links are followed from. Without include patterns the crawl stays on these sites.
sitemapsstring[]Sitemaps or sitemap indexes. Sitemaps named in robots.txt are read anyway.
include, excludestring[]URL patterns where * is any run of characters.
maxUrlsnumberPages read per crawl, up to 50,000. Defaults to 1,000.
schedulestringmanual, daily or weekly.
Request
curl -X POST https://api.dbrij.com/api/search/crawlers \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Docs", "index": "docs", "startUrls": ["https://docs.example.com"], "exclude": ["*/archive/*"], "schedule": "daily" }'
Request body
{ "name": "Docs", "index": "docs", "startUrls": ["https://docs.example.com"], "exclude": ["*/archive/*"], "schedule": "daily" }
POST/search/crawlers/:crawlerId/runsearch:manage

Crawl now

Starts in the background; the crawler’s status and lastRun follow it.

Request
curl -X POST https://api.dbrij.com/api/search/crawlers/:crawlerId/run \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
GET/search/crawlerssearch:manage

List crawlers

Each crawler with its last run: pages read, records written and removed, failures, and any error in words.

Request
curl -X GET https://api.dbrij.com/api/search/crawlers \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
GET/search/crawlers/:crawlerId/runssearch:manage

Recent crawls

The last 20 runs.

Request
curl -X GET https://api.dbrij.com/api/search/crawlers/:crawlerId/runs \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
PUT/search/crawlers/:crawlerIdsearch:manage

Change a crawler

As on create.

Request
curl -X PUT https://api.dbrij.com/api/search/crawlers/:crawlerId \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
DELETE/search/crawlers/:crawlerIdsearch:manage

Remove a crawler

The pages it made stay in the index until you remove them.

Request
curl -X DELETE https://api.dbrij.com/api/search/crawlers/:crawlerId \
  -H "Authorization: Bearer $DBRIJ_API_KEY"

Answers and plain language

Two ways the index reads people the way they write, both off until you turn them on under the index’s Learning tab (or with the answers and understanding settings), because both spend the account’s AI credits.

answers { enabled, fields, instructions, quality: fast | best, maxSources: 1 to 10 }. fields are what is read from each record (empty reads them all); instructions are your words on how to answer.

POST/search/indexes/:name/answersearch:query

Answer a question

An ordinary search first (rules, learned ranking, your filter and a scoped key’s all apply, and it counts as a search), then the best records go to the model as numbered sources. Every sentence of the answer cites the records it rests on, and when they do not answer the question you get answered: false instead of an invented answer. A repeat within ten minutes is served from the cache at no AI cost. Spends the account’s AI credits, so it is off until answers are turned on for the index.

PATH / BODY PARAMETERS

q*stringThe question, up to 500 characters.
filterstringOnly answer from records matching this.
userTokenstringAs on a search: personalizes which records are read.
ruleContextsstring[]As on a search.
Request
curl -X POST https://api.dbrij.com/api/search/indexes/:name/answer \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "q": "How long do I have to return something?" }'
Request body
{ "q": "How long do I have to return something?" }
Response 200
{
  "success": true,
  "data": {
    "answer": "You have 30 days from delivery to return an item, unworn and with its tags [1]. Refunds land within 5 working days of the return arriving [2].",
    "answered": true,
    "sources": [{ "id": "help-returns", "ref": 1, "cited": true, "document": { "title": "Returns" } }, { "id": "help-refunds", "ref": 2, "cited": true, "document": { "title": "Refunds" } }],
    "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77",
    "cached": false,
    "tookMs": 1840
  }
}

understanding { enabled }. Send understand: true on a search with three words or more, and it is read into words, a filter and a sort against the index’s own fields and a sample of their values. Everything read is checked before it is used: a filter that does not parse or names a field the index does not filter on is dropped, and so is a sort on a field that is not sortable. The read filter is ANDed onto yours, so it can only narrow, and your own sort always wins. The response says what was read in understood. Each new phrasing costs one model call and is remembered for a day; if anything goes wrong the search runs as typed.

Recommendations

From the same records and the same events as search, with nothing to set up. Related works from day one; the others fill in as events arrive and are recomputed every hour.

  • related: records like this one, by meaning when the index has semantic search on (the record’s nearest neighbours), else by its words.
  • boughtTogether: records that at least two different people converted on together, within a day of each other, over the last 90 days.
  • trending: records at least three people chose this week, ranked by how much faster than last week, weighed by how many.
  • trendingValues: the same, rolled up by a field’s values (the rising brands or categories).
POST/search/indexes/:name/recommendsearch:query

Recommendations

Counted as one search. Never returns the record asked about, and honours your filter and a scoped key’s.

PATH / BODY PARAMETERS

model*stringrelated, boughtTogether, trending or trendingValues.
objectIdstringFor related and boughtTogether: the record to recommend from.
facetstringFor trendingValues: a filterable field, like brand.
filterstringOnly recommend records matching this, like inStock = true.
limitnumber1 to 30, defaults to 10.
Request
curl -X POST https://api.dbrij.com/api/search/indexes/:name/recommend \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "model": "boughtTogether", "objectId": "sku-1", "filter": "inStock = true" }'
Request body
{ "model": "boughtTogether", "objectId": "sku-1", "filter": "inStock = true" }
Response 200
{
  "success": true,
  "data": { "model": "boughtTogether", "hits": [{ "id": "sku-7", "document": { "title": "Running socks" }, "score": 14 }] }
}

Analytics API

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.

GET/search/analytics?days=30search:manage

Query 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.

Request
curl -X GET https://api.dbrij.com/api/search/analytics?days=30 \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
Response 200
{
  "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.

Plans, errors and limits

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.

Typing is one search. Search as you type sends a request on every keystroke; you are billed for the search, not the keystrokes. Requests from the same person on the same index within 30 seconds of each other, whose words extend, shorten or repeat the last ones, count as the one search they started, along with its pages and refinements. Suggestions count the same way. A session lasts at most two minutes and 50 requests. The person is your userToken when you send one, else the browser, so send a userToken from a server that searches on behalf of many people.

PlanDocuments includedUp toEach extra 1,000 a monthSearches a monthIndexesRequests a minuteSemantic
Free10,00010,000Hard ceiling3,000260No
Starter100,000300,000₦130500,0005600Yes
Growth300,0001,000,000₦1305,000,000201,200Yes
Scale900,00010,000,000₦13050,000,0001003,000Yes

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

207partial writeSome 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.
400bad requestA 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.
401unauthorizedMissing, revoked or wrong key.
403forbiddenThe key is real but lacks the scope, e.g. a search only key trying to write documents.
402plan limitThe 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.
404not foundNo index by that name in this workspace.
422nothing indexedThe 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.
429rate limitedPast 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`.
503unavailableThe 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.

Everything on Dbrij

More from Dbrij