Home
Developer API v1

querrio Search API v1 — hybrid retrieval with LLM reranking via REST

Per-request pipeline: intent parsing → hybrid retrieval (Postgres full-text + pgvector) → LLM reranking when score margins are tight. The response is JSON with top_hits, results, groups, suggestions and a click_url per hit. Authenticate with a browser key (pk_…, origin-bound) or a server key (sk_…). Language is resolved against the project languages. Indexing and ranking tuning run server-side.

1 · Widget (drop-in)

One script tag, renders in Shadow DOM with no CSS conflicts. Configure in the dashboard, override with custom CSS.

<script src="https://cdn.querrio.com/widget.js"
  data-key="pk_your_browser_key" defer></script>

2 · JS SDK (headless)

~2 KB, zero dependencies, type definitions at /sdk.d.ts. Includes debouncing, session ID, suggest request abort and tracking via sendBeacon.

<script src="https://cdn.querrio.com/sdk.js"></script>
<script>
  const client = querrio.createClient({ key: 'pk_your_browser_key' });

  const res = await client.search('example query');
  // res.top_hits    → 1–2 hits for teaser display
  // res.results     → remaining hits as a list
  // res.groups      → categories with count and hit IDs
  // res.suggestions → "popular searches" as tags
  // res.intent      → detected intent, period, audience

  client.trackClick(res.results[0], 1);
</script>

TypeScript types

The complete types (incl. categories, tier weighting, intent and debug object) are available at /sdk.d.ts. Drop the file in or fetch it with curl — autocompletion and type checking then work for the SDK and REST responses.

curl -o types/querrio.d.ts https://cdn.querrio.com/sdk.d.ts

// app.ts
/// <reference path="./types/querrio.d.ts" />

const client = window.Querrio.createClient({ key: 'pk_your_browser_key' });

const res: querrio.SearchResponse = await client.search('example query');
res.top_hits[0].tier;        // "top" | "list"
res.groups[0].category;      // "event" | "article" | "product" | …
res.intent.date_from;        // "2026-04-19" or null
client.trackClick(res.results[0], 1);

Before the first keystroke: topic tags & popular searches

Everything shown before the first keystroke comes from GET /api/public/v1/schema. The autopilot maintains the lists automatically (or your team manually in the dashboard) – per website language.

FieldContentWhere to show
widget.topic_tags_by_langShort topics (1–3 words, max. 8), e.g. “Theatre”, “Concerts”As tags directly below the search field, always visible
widget.quick_chips_by_langSearch phrases (max. 6), e.g. “Renew subscription” – from real searches, AI suggestions during the start phaseIn the empty search field under “Popular searches”
entry_pointsMost important target pages (url, title, page_kind)Optionally as quick entries in the empty search field
widget.config.placeholdersPlaceholder text per languageplaceholder of the search field
  • Language: Always use the list for the current page language. If there is none, show nothing – never another language. topic_tags/quick_chips without _by_lang contain only the primary language.
  • Click: Send the term as a search to /v1/search and report it as suggest_click – otherwise the autopilot can't learn.
  • Caching: The lists change at most daily; a 1-hour browser cache is fine.
// GET /api/public/v1/schema  (Header: x-querrio-key: pk_…)
{
  "languages": ["de", "en"],
  "entry_points": [{ "url": "https://…/abo", "title": "Abonnements", "page_kind": "page" }],
  "widget": {
    "primary_language": "de",
    "config": { "placeholders": { "de": "Stellen Sie eine Frage", "en": "Ask a question" } },
    "topic_tags_by_lang":  { "de": ["Kulturfahrten", "Theater", "Abonnements", "Konzerte"], "en": ["Theatre", "Concerts"] },
    "quick_chips_by_lang": { "de": ["Abonnement verlängern", "Veranstaltungen für Kinder"], "en": ["Renew subscription"] },
    "topic_tags":  ["Kulturfahrten", "Theater"],          // primary language (compatibility)
    "quick_chips": ["Abonnement verlängern"]             // primary language (compatibility)
  }
}
const KEY = "pk_…", API = "https://api.querrio.com";
const lang = (document.documentElement.lang || "de").slice(0, 2);
const s = await fetch(API + "/api/public/v1/schema", { headers: { "x-querrio-key": KEY } }).then(r => r.json());
const pick = (byLang) => byLang?.[lang] ?? []; // never show another language

input.placeholder = s.widget.config.placeholders?.[lang] ?? input.placeholder;
tagRow.innerHTML = pick(s.widget.topic_tags_by_lang)
  .map(t => `<button class="tag">${t}</button>`).join("");

tagRow.addEventListener("click", (e) => {
  const t = e.target.closest(".tag"); if (!t) return;
  input.value = t.textContent;
  runSearch(input.value);                 // POST /api/public/v1/search
  fetch(API + "/api/public/v1/track", {   // click counts for the autopilot
    method: "POST", headers: { "content-type": "text/plain" },
    body: JSON.stringify({ key: KEY, kind: "suggest_click" }), // the term itself arrives via the search
  });
});

// In the empty search field (focused, nothing typed yet): pick(s.widget.quick_chips_by_lang)
function SearchStart({ schema, lang, onSearch }) {
  const tags = schema.widget.topic_tags_by_lang?.[lang] ?? [];
  return (
    <div className="tags">
      {tags.map(t => (
        <button key={t} onClick={() => { onSearch(t); client.trackSuggestClick(t); }}>{t}</button>
      ))}
    </div>
  );
}

Reference implementations: vanilla JS, React, server

Complete headless integration: suggest input, top-hit rendering, facet filters via groups, quick chips, zero-result state and click tracking. Replace the key and run.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Custom search with querrio</title>
  <script src="https://cdn.querrio.com/sdk.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; }
    input { width: 100%; padding: 12px 16px; border: 1px solid #ccc; border-radius: 999px; font-size: 16px; }
    .suggest { list-style: none; padding: 0; margin: 4px 0; border: 1px solid #eee; border-radius: 12px; }
    .suggest li { padding: 8px 14px; cursor: pointer; }
    .suggest li:hover { background: #f4f4f5; }
    .chips button { margin: 4px 4px 0 0; padding: 4px 12px; border: 1px solid #ddd; border-radius: 999px; background: #fff; cursor: pointer; }
    .chips button.active { background: #111; color: #fff; }
    .hit { display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid #eee; text-decoration: none; color: inherit; }
    .hit img { width: 96px; height: 64px; object-fit: cover; border-radius: 8px; }
    .top { background: #f7f7f8; border-radius: 12px; padding: 12px; }
    small { color: #666; }
  </style>
</head>
<body>
  <input id="q" type="search" placeholder="What are you looking for?" autocomplete="off" />
  <ul id="suggest" class="suggest" hidden></ul>
  <div id="filters" class="chips"></div>
  <div id="out"></div>
  <p><small>Search by querrio</small></p>

  <script>
    const client = querrio.createClient({ key: "pk_your_browser_key" });
    const $ = (id) => document.getElementById(id);
    let last = null;       // last response
    let activeCat = null;  // selected category filter
    let timer;

    // Suggestions while typing
    $("q").addEventListener("input", () => {
      clearTimeout(timer);
      const q = $("q").value.trim();
      if (q.length < 2) { $("suggest").hidden = true; return; }
      timer = setTimeout(async () => {
        const s = await client.suggest(q);
        if (!s.enabled || !s.items.length) { $("suggest").hidden = true; return; }
        $("suggest").innerHTML = s.items
          .map((i) => `<li data-text="${i.text}">${i.text}</li>`).join("");
        $("suggest").hidden = false;
      }, 150);
    });

    $("suggest").addEventListener("click", (e) => {
      const text = e.target.dataset.text;
      if (!text) return;
      client.trackSuggestClick(text);
      $("q").value = text;
      $("suggest").hidden = true;
      run(text);
    });

    $("q").addEventListener("keydown", (e) => {
      if (e.key === "Enter") { $("suggest").hidden = true; run($("q").value); }
    });

    async function run(query) {
      if (!query.trim()) return;
      $("out").innerHTML = "<small>Searching …</small>";
      activeCat = null;
      last = await client.search(query);
      render();
    }

    function hitHtml(h, pos, top) {
      return `<a class="hit ${top ? "top" : ""}" href="${h.url}" data-pos="${pos}" data-id="${h.id}">
        ${h.image_url ? `<img src="${h.image_url}" alt="" />` : ""}
        <div><strong>${h.title}</strong><br /><small>${h.snippet ?? ""}</small></div>
      </a>`;
    }

    function render() {
      const r = last;
      const all = [...r.top_hits, ...r.results];

      // No hits: offer suggested terms
      if (!all.length) {
        $("filters").innerHTML = "";
        $("out").innerHTML = `<p>Nothing matching yet. Try:</p>
          <div class="chips">${r.suggestions.map((s) => `<button data-q="${s}">${s}</button>`).join("")}</div>`;
        return;
      }

      // Category filters from the groups
      $("filters").innerHTML = r.groups.map((g) =>
        `<button data-cat="${g.category}" class="${g.category === activeCat ? "active" : ""}">${g.label} (${g.count})</button>`
      ).join("");

      const visible = activeCat
        ? all.filter((h) => r.groups.find((g) => g.category === activeCat)?.hit_ids.includes(h.id))
        : null;

      $("out").innerHTML =
        (r.date_label ? `<small>Period: ${r.date_label}</small>` : "") +
        (visible
          ? visible.map((h, i) => hitHtml(h, i + 1, false)).join("")
          : r.top_hits.map((h, i) => hitHtml(h, i + 1, true)).join("") +
            r.results.map((h, i) => hitHtml(h, r.top_hits.length + i + 1, false)).join("")) +
        (r.suggestions.length
          ? `<p><small>Popular searches</small></p><div class="chips">${r.suggestions
              .map((s) => `<button data-q="${s}">${s}</button>`).join("")}</div>`
          : "");
    }

    // Filter and keyword clicks
    document.addEventListener("click", (e) => {
      const t = e.target.closest("button");
      if (!t) return;
      if (t.dataset.cat) { activeCat = activeCat === t.dataset.cat ? null : t.dataset.cat; render(); }
      if (t.dataset.q) { $("q").value = t.dataset.q; run(t.dataset.q); }
    });

    // Click tracking: analytics + autopilot learn from it
    $("out").addEventListener("click", (e) => {
      const a = e.target.closest("a.hit");
      if (!a || !last) return;
      const hit = [...last.top_hits, ...last.results].find((h) => h.id === a.dataset.id);
      if (hit) client.trackClick(hit, Number(a.dataset.pos));
    });
  </script>
</body>
</html>

Go-live checklist

  • Use only the browser key (pk_…) in the browser and allow your domain in the dashboard under “Widget & API”.
  • Use the server key only on your server – never in browser code.
  • Add click tracking – without it analytics and the autopilot can't learn.
  • Keep the “Search by querrio” notice visible as required by your plan.

3 · REST API

Server key in the header x-querrio-key. Browser keys are restricted to the allowed origins.

curl -X POST https://api.querrio.com/api/public/v1/search \

  -H 'content-type: application/json' \
  -H 'x-querrio-key: sk_your_server_key' \
  -d '{ "query": "example query", "limit": 10 }'

Tracking with API integration (required for the autopilot)

querrio counts searches and shown results automatically when /v1/search is called. With your own UI, querrio cannot see clicks and feedback by itself – they must be reported. Otherwise click rates stay empty. The autopilot will then never demote anything, but it also cannot learn.

Visitor searches → POST /v1/search  → search + shown results are counted
                                      response contains query_id and per hit id + click_url
Visitor clicks   → Option A: link via hit.click_url  (counted automatically)
                 → Option B: POST /v1/track { kind: "click", query_id, document_id, position }

Option A · click_url (recommended)

Every hit includes click_url via go.querrio.com. The link counts the click and instantly redirects to the stored page URL. A click only counts if the hit was shown in exactly that search. No cookies, bots are not counted. url stays unchanged if you prefer direct links.

Zero-Click Actions · hit.action

For transactional query intent (book, tickets, buy, offer, enquire, reserve) the top matching hit carries action { kind, label, target_url, action_url }. Intent detection is deterministic + LLM; page actions are extracted at crawl time from schema.org potentialAction, CTA anchors and URL patterns (confidence ≥ 0.6). Ranking boost max +0.15, only when base score ≥ 0.3. action_url records action_click and 302-redirects to the crawl-validated target_url — no open redirect. label is localized; render it as a CTA on the hit.

<a href="${hit.click_url}" rel="nofollow">${hit.title}</a>

Option B · Report events via API

// SDK
client.trackClick(hit, position);          // click on a hit
client.trackSuggestClick(text, hit?.id);   // click on a suggestion, topic tag or "popular searches"

// REST (text/plain so that sendBeacon works cross-origin)
navigator.sendBeacon("https://api.querrio.com/api/public/v1/track",
  new Blob([JSON.stringify({ key, kind: "click", query_id, document_id: hit.id, position })],
  { type: "text/plain" }));

Fields of POST /api/public/v1/track

FieldRequiredContent
keyyes*Browser or server key (*alternatively header x-querrio-key)
kindyesEvent type, see below
query_idrecommendedFrom the search response – links the event to the search
document_idfor clickshit.id of the clicked hit
positionfor clicksPosition in the displayed list, starting at 1
dwell_msfor dwellTime spent on the target page in milliseconds
session_idoptionalRandom ID per browser tab (no cookie). Detects reformulations and abandons; send the same ID with /v1/search

Event types (kind)

  • click – Click on a hit (most important signal for the autopilot)
  • suggest_click – Click on a suggestion, topic tag or popular search
  • helpful / not_helpful – Answer to “Was this helpful?”
  • dwell – Time spent after the click
  • conversion – Goal reached (e.g. booking, purchase, sign-up)
  • reformulation / abandon – Search rephrased, or left without a click

Responses

  • 200 ok – stored
  • 400 – kind missing or unknown
  • 401 / 403 – invalid key, or domain not allowed
  • 429 – too many events (120 per minute and visitor), respect the Retry-After header

Excluding tests & privacy

  • Send test calls (search and tracking) with the server key and the header x-querrio-test: 1 – they then count neither in analytics nor towards your quota. Calls from the querrio dashboard and from tools like curl using a browser key are detected as tests automatically.
  • No cookies, no fingerprinting; IP addresses are not stored. Tracking needs no consent banner.
  • Whether clicks arrive is shown in the dashboard under “Setup” (click reporting yes/no).

Auto-Suggest

GET /api/public/v1/suggest?q= returns suggestions from successful past searches and from titles and categories in the index — so it works right after the first crawl. Activation, minimum characters, count and direct hits are set in the dashboard under “Widget & API”; if auto-suggest is off, the API responds with enabled: false and empty lists.

// Typeahead: suggestions while typing
const client = querrio.createClient({ key: "pk_your_browser_key" });

input.addEventListener("input", async () => {
  const res = await client.suggest(input.value); // older requests are cancelled automatically
  if (!res.enabled) return;                    // disabled in the dashboard
  render(res.items);  // [{ text: "example term", type: "popular" | "content" }]
  render(res.hits);   // direct hits: id, url, title, category, image_url
  showDate(res.date_label); // detected time reference, e.g. "tomorrow"
});

// Report a click on a suggestion (feeds analytics and autopilot):
client.trackSuggestClick(item.text);
client.trackSuggestClick(hit.title, hit.id);

Response structure

{
  "query": "example query",
  "intent": { "topic": "example topic", "audience": null },
  "date_from": null, "date_to": null, "date_label": null,
  "top_hits": [
    { "id": "…", "url": "https://…", "title": "Example page title",
      "snippet": "…", "image_url": "https://…", "category": "article",
      "tags": ["example"], "score": 0.91, "tier": "top",
      "is_event": false, "event_start": null,
      "event_location": null }
  ],
  "results": [ … ],
  "groups": [ { "category": "article", "label": "Articles", "count": 6, "hit_ids": [ … ] } ],
  "suggestions": ["related search term", "another search term"],
  "total": 7, "took_ms": 240, "query_id": "…"
}

Debug mode

"debug": true (or ?debug=1) adds a debug object to the response: parsed intent, resolved date/audience with source, active filters and ranking weights, retrieval candidates before reranking, order before/after, zero-result rescue, teaser thresholds, per-hit score breakdown and stage timings. Server keys: always allowed. Browser keys: must be enabled under “Widget & API”, otherwise header x-asky-debug: denied and no debug object.

curl -X POST https://api.querrio.com/api/public/v1/search \
  -H 'content-type: application/json' \
  -H 'x-querrio-key: sk_your_server_key' \
  -d '{ "query": "example query", "debug": true }'
"debug": {
  "version": 1,
  "query": { "raw": "example query",
             "normalized": "example query",
             "language": "de", "limit": 10, "candidate_limit": 30 },
  "intent": { "topic": "example topic", "audience": null,
              "entities": { … }, "prompt_override": false },
  "resolved": { "date_from": null, "date_to": null,
                "date_label": null, "date_source": "none",
                "audience": null, "audience_source": "none",
                "now": "2026-04-18T09:12:00Z" },
  "filters": { "date_from": null, "date_to": null,
               "audience": null, "category": null, "tags": [] },
  "weights": { "vector": 0.6, "fts": 0.25, "quality": 0.1,
               "date_boost": 0.15, "audience_boost": 0.1 },
  "candidates": [ { "rank": 1, "id": "…", "url": "https://…",
                    "title": "Example page title", "score": 0.83,
                    "is_event": false, "event_start": null } ],
  "rerank":  { "applied": true, "model_used": true, "before": [ … ], "after": [ … ] },
  "rescue":  { "attempted": false, "from_cache": false, "candidates": [], "used": null },
  "tiering": { "top_score": 0.91, "max_top_hits": 2,
               "tier_threshold": 0.12, "top_hit_ids": [ … ] },
  "scores":  [ { "id": "…", "position": 1, "final_score": 0.91,
                 "relative_to_top": 1, "tier": "top", "category": "article" } ],
  "timings": { "config_ms": 12, "intent_embed_ms": 180, "retrieval_ms": 41,
               "rerank_ms": 260, "total_ms": 512 },
  "notes": []
}

Endpoints

  • POST/api/public/v1/searchIntent search. Returns top hits, list, categories and suggestions.
  • GET/api/public/v1/suggest?q=Typeahead: suggestions and instant hits.
  • GET/api/public/v1/schemaInitial state: topic tags, popular searches, entry pages, placeholders, categories and languages.
  • POST/api/public/v1/trackReport clicks, dwell time and feedback.
  • GET/api/public/v1/openapi.jsonOpenAPI 3.1 specification for codegen.

Limits & notes

  • Rate limit: 120 requests/min per project, plus 30/min per IP with browser keys. When exceeded: 429 with retry-after header.
  • Error codes: 400 missing key, 401 invalid key, 403 origin not allowed or bot, 500 internal.
  • Click and feedback events feed the ranking as learning signals (autopilot).
  • All endpoints send CORS headers, no proxy required.