Engines
Native clients (api.headband.dev)
Every engine's own API is served at one address, with one key per environment:
https://api.headband.dev/<engine>/<the engine's own path><engine> is meilisearch, typesense, elasticsearch or visor. Nothing is translated: the path, the query, the body and the answer are the engine's own, so every option and every score passes through. Keep the official client you already use. Give it this base URL and a Headband key.
Before you start
- A project with engines. Open Engines in the dashboard, click Create engine and pick the project: the engine serves the project's main branch. Create one per engine type you want to reach. With an organization-wide admin key you can also call
POST /v1/instanceswith aprojectId(see Create an engine). - A branch key. The two keys a project shows when you create it, admin (
hb_adm_) and search (hb_src_), are keys of its main branch. Any key you create on a branch works too. An organization-wide key is refused here, because it names no environment.
The key decides the environment. The same URL with a preview branch's key reaches that branch's engines, so switching environments means switching one key.
Meilisearch
import { Meilisearch } from "meilisearch";
const base = "https://api.headband.dev";
const client = new Meilisearch({
host: base + "/meilisearch",
// the branch's admin key, or its
// search key to search only
apiKey: process.env.HEADBAND_API_KEY,
});
const movies = [
{
id: "1",
title: "Carol",
genre: "drama",
},
{
id: "2",
title: "Wonder Woman",
genre: "action",
},
];
await client
.createIndex("movies", {
primaryKey: "id",
})
.waitTask();
await client
.index("movies")
.addDocuments(movies)
.waitTask();
const { hits } = await client
.index("movies")
.search("wonder");export ADMIN_KEY="hb_adm_..."
API=https://api.headband.dev/meilisearch
curl -X POST \
"$API/indexes/movies/search" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"q": "wonder"}'Typesense
Set the node's path to /typesense.
import Typesense from "typesense";
const client = new Typesense.Client({
nodes: [{
host: "api.headband.dev",
port: 443,
protocol: "https",
path: "/typesense",
}],
apiKey: process.env.HEADBAND_API_KEY,
});
await client.collections().create({
name: "movies",
fields: [
{ name: "title", type: "string" },
{
name: "genre",
type: "string",
facet: true,
},
],
});
const movies = [
{
id: "1",
title: "Carol",
genre: "drama",
},
{
id: "2",
title: "Wonder Woman",
genre: "action",
},
];
await client
.collections("movies")
.documents()
.import(movies, { action: "upsert" });
const { hits } = await client
.collections("movies")
.documents()
.search({
q: "wonder",
query_by: "title",
});A search that names no preset, and each search of a multi search that names none, uses the index's search profile. Anything the request sets itself wins.
In the browser, send the key in a header
SearchClient, puts the key in the query string by default, which answers 401 missing_credentials. Create it with sendApiKeyAsQueryParam: false.Elasticsearch
Install client 8: npm install @elastic/elasticsearch@8. Client 9 sends a version 9 header that the version 8 engines refuse with a 400.
import {
Client,
} from "@elastic/elasticsearch";
const base = "https://api.headband.dev";
const client = new Client({
node: base + "/elasticsearch",
auth: {
apiKey: process.env.HEADBAND_API_KEY,
},
});
const movies = [
{
id: "1",
title: "Carol",
genre: "drama",
},
{
id: "2",
title: "Wonder Woman",
genre: "action",
},
];
await client.indices.create({
index: "movies",
});
await client.helpers.bulk({
datasource: movies,
onDocument: (doc) => ({
index: {
_index: "movies",
_id: doc.id,
},
}),
});
await client.indices.refresh({
index: "movies",
});
const result = await client.search({
index: "movies",
query: { match: { title: "wonder" } },
});Visor
Visor has no published client. Call its HTTP API.
API=https://api.headband.dev/visor/v1
# Create the index
curl -X PUT \
"$API/indexes/movies" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"primaryKey": "id"}'
# Add documents
curl -X POST \
"$API/indexes/movies/documents" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '[
{"id": "1", "title": "Carol"},
{"id": "2", "title": "Wonder Woman"}
]'
# Search: mode is instant (the default)
# or retrieval, for longer queries
curl -X POST \
"$API/indexes/movies/search" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "wonder",
"mode": "instant",
"limit": 20
}'Laravel Scout
Scout's Meilisearch and Typesense drivers work unchanged. Install Scout and the engine's PHP client as Scout's docs say. On a current Laravel, Guzzle already provides the HTTP factories the Meilisearch client needs, so http-interop/http-factory-guzzle is not needed.
Meilisearch
If you already run Scout on Meilisearch, change two variables: the host and the key.
SCOUT_DRIVER=meilisearch
HEADBAND=https://api.headband.dev
MEILISEARCH_HOST=${HEADBAND}/meilisearch
# the branch's admin key
MEILISEARCH_KEY=hb_adm_...Typesense
Point the node at the entry point. Keep your model-settings in config/scout.php as they are.
SCOUT_DRIVER=typesense
TYPESENSE_HOST=api.headband.dev
TYPESENSE_PORT=443
TYPESENSE_PROTOCOL=https
TYPESENSE_PATH=/typesense
TYPESENSE_API_KEY=hb_adm_...Then index and search as usual: php artisan scout:import, and Model::search('wonder')->get(). Scout writes, so it needs the admin key. A search key reaches search routes only.
Which engine a request reaches
Among the engines of the key's branch that the key reaches:
/meilisearch/reaches the only Meilisearch engine, or, with several, the one marked default./meilisearch~catalog/reaches the engine whose id iscatalogor, failing that, the one namedcatalog. Names use letters, digits,.,_and-, up to 64 characters; pick any other engine by its id.- Nothing is guessed. Several engines of the kind and no single default, or two engines with the same name, answer
409 instance_ambiguousand list them. Pick one by its id.
A forked branch copies its parent's engine names and defaults, so the same URL, ~name included, reaches the matching engine of whichever branch the key belongs to. See Branches.
Keys and what they reach
The engine checks the key the same way its own endpoint does (Keys on the endpoint):
- An admin key with the default permissions reaches searches, reads, writes and index settings.
- A search key reaches searches only. A write answers
403 route_not_allowed. - A branch key with a list of engines reaches those engines only. Index-limited keys and tenant tokens are not accepted here.
- Key and cluster administration is never reachable, whatever the key: Meilisearch and Typesense
/keys, Elasticsearch security, cluster and node settings, snapshots and dumps.
Send the key as Authorization: Bearer, ApiKey or Basic (the key as the password), or X-TYPESENSE-API-KEY, on any engine. A key in the query string is never read.
Sleeping engines
A request for a sleeping engine wakes it and waits, then gets its answer as usual: up to 10 seconds on Free and 25 seconds on paid plans. Past that it gets 503 instance_waking with Retry-After. The wake times page shows how long wakes take in production.
Errors
The entry point answers these itself, in the gateway's shape (error, code, message). Other answers come from the engine, or are gateway errors.
Where it runs
api.headband.dev runs on both servers of our one region, US East (New York, ewr1), behind a health-checked load balancer. It reaches engines in that region. Calls from a browser work, with the same CORS rules as an engine's own endpoint. The REST API is on the same host, at https://api.headband.dev/v1.
Connect reliably
api.headband.dev answers with the addresses of both servers, for 30 seconds at a time. If one server loses its network, the load balancer's health checks take its address out of the answer, but until then a client can still pick it. A new connection to that address gets no answer at all, not an error. Three settings keep your searches answering meanwhile.
- A short connect timeout, about 1 second. Many clients wait 10 seconds or more by default before they give up on a connect.
- The other address when a connect fails. Node 20 and later already do this for
fetchand every client built on it: a connect with no answer within 250 ms (500 ms on Node 26) moves on to the next address. Node 18 does not, so use Node 20 or later. Python'srequeststries each address in turn with the connect timeout, so set one:timeout=(1, 30). With curl, set--connect-timeout 2. Elsewhere, check that your client tries the next address when a connect fails. - Kept-alive connections. Reuse connections instead of opening one per request. A connection already open to the server that is up keeps working while the other is down. Most official clients reuse connections by default.
Retry a search once if it fails before any answer comes back. A write whose connection broke after it was sent may have landed, so check before you send it again (see Errors).