Native clients (api.headband.dev)

Engines

Native clients (api.headband.dev)

Every engine's own API is served at one address, with one key per environment:

URL
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

  1. 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/instances with a projectId (see Create an engine).
  2. 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

Node
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");
curl
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.

Node
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

Typesense's browser client, 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.

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

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

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

.env
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 is catalog or, failing that, the one named catalog. 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_ambiguous and 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.

Status and codeMeaning
401 missing_credentialsNo key in a header the gateway reads.
401 invalid_credentialsThe key does not exist, was revoked or has expired.
401 key_not_readyAn older key the entry point has not seen in use yet. One request to the REST API fixes it.
403 branch_key_requiredAn organization-wide key. Use a branch key.
403 route_not_allowedThe key cannot reach this route, such as a write with a search key, or engine administration.
404 engine_not_in_environmentThe key reaches no engine of that kind, or none with that id or name.
409 instance_ambiguousSeveral engines of the kind and no single default, or two with that name. Pick one by id.
400 invalid_engine_selectorThe part after ~ is not a valid name or id.
503 branch_not_readyThe branch is being created or deleted. Retry after Retry-After.
503 state_unavailableThe router is starting or cannot reach our control plane. Retry after Retry-After.
503 instance_unavailableThe engine's server did not answer, or the connection broke. Retry after Retry-After. If the connection broke after a write went out, the write may have landed: check before you send it again.
504 gateway_timeoutThe engine did not start answering within 11 minutes. A write may have landed.

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 fetch and 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's requests tries 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).