API Documentation

TypoSuggest provides instant, typo-tolerant search suggestions across product titles, marketplace gigs, articles, and catalog databases. Matching takes under a millisecond, keeping your primary database free from keystroke traffic.

Authentication & Dual Keys

Every account receives two roles of API keys designed for separate environments:

Public Search Key (ac_pub_...)

Safe to embed directly in browser JavaScript, React components, or mobile applications. Permitted exclusively on GET /v1/suggest. Cannot write, modify, or delete data.

Secret Write Key (ac_sec_...)

Restricted to your backend server. Grants full administrative permissions to create datasets, ingest rows, and delete records. Keep private.

Pass your API key in all HTTP requests via header: X-API-Key: ac_pub_your_key_here
AI Coding Integration

Vibe Coder / AI Agent Setup (MCP & Prompt)

Native MCP /mcp

TypoSuggest provides a native Model Context Protocol (MCP) server so coding agents in Cursor, Claude Code, Windsurf, or Antigravity can inspect your datasets, add phrases, and test suggestions autonomously. You can also copy a pre-filled, zero-hallucination prompt directly into your AI chat.

Cursor / Windsurf / Claude Code Configuration (mcp.json):
{
  "mcpServers": {
    "typosuggest": {
      "url": "https://typosuggest.pial.digital/mcp",
      "headers": {
        "X-API-Key": "ac_sec_your_secret_key_here"
      }
    }
  }
}

How Ranking Works

TypoSuggest handles relevance for you—no manual tuning needed. Each query is checked in four stages, and phrases you have uploaded more often rank higher:

  • Stage 1 (Starts With The Query): Suggests phrases that begin with the words you typed. Ranked by how often the phrase appears in your data (returned as frequency).
  • Stage 2 (Contains The Query): Suggests phrases that contain the words you typed anywhere inside the title.
  • Stage 3 (Typos Allowed): Corrects spelling mistakes (e.g., macbok → macbook, wrodpres → wordpress) and matches them in under a millisecond.
  • Stage 4 (Partial Words): Suggests individual words when longer phrases are not found.
GET

/v1/suggest

Query suggestions from a specific dataset. Allowed with either public or secret key.

Query Parameters:
  • dataset_id (required): The UUID or string ID of the target dataset.
  • q (required): The search prefix or query text (e.g. macbok).
  • key (optional): Public search key (ac_pub_...). Passing ?key=... eliminates browser CORS OPTIONS preflight requests completely!
  • limit (optional, default 8): Number of suggestions to return (max 20).
  • typo (optional, default true): Turn typo tolerance on or off.
Skip the Browser Preflight Round-Trip (& Security Trade-Off)

You can authenticate GET /v1/suggest using either the X-API-Key: ac_pub_... header OR the ?key=ac_pub_... URL query parameter. Passing ?key=... makes the call a CORS simple request, so the browser skips the OPTIONS preflight round-trip.

Security Note: Query parameters can be recorded in proxy/CDN logs, browser history, and monitoring systems. Only pass public, read-only keys (ac_pub_...) in URLs—never pass secret keys (ac_sec_...) or session tokens in query strings.

// Request:
GET https://typosuggest.pial.digital/v1/suggest?dataset_id=YOUR_DATASET_ID&q=macbook&limit=5
X-API-Key: ac_pub_YOUR_KEY
// Response (HTTP 200 OK):
{
  "query": "macbook",
  "took_ms": 0.08,
  "suggestions": [
    { "text": "Apple MacBook Pro 16 inch M3 Max", "score": 24.5, "frequency": 2, "group": "laptop" },
    { "text": "MacBook Air M2 13 inch", "score": 18.2, "frequency": 1, "group": "laptop" }
  ]
}
Zero-Code Drop-In Widget for WordPress, Shopify, Webflow & HTML (/widget.js):
<!-- Option A: WordPress, WooCommerce & Shopify (paste in site header — auto-attaches to input[name="s"] & input[name="q"]) -->
<script
  src="https://typosuggest.pial.digital/widget.js"
  data-key="ac_pub_YOUR_KEY"
  data-dataset="YOUR_DATASET_ID"
  defer
></script>

<!-- Option B: Any Custom HTML <input> -->
<script src="https://typosuggest.pial.digital/widget.js" defer></script>
<input
  type="search"
  data-typosuggest-key="ac_pub_YOUR_KEY"
  data-typosuggest-dataset="YOUR_DATASET_ID"
  placeholder="Search products..."
/>
POST

/v1/datasets/{id}/items:add

Ingest raw texts or structured items. Requires secret key or Bearer token.

// Request Payload:
{
  "items": [
    {
      "id": "item_901",
      "text": "I will build a modern WordPress website for your business",
      "group": "web-development",
      "count": 10
    },
    {
      "id": "item_902",
      "text": "Apple MacBook Pro 16 inch M3 Max",
      "group": "product",
      "count": 25
    },
    {
      "id": "item_903",
      "text": "Responsive React Tailwind Dashboard",
      "group": "web-development",
      "count": 5
    }
  ]
}
// Response (HTTP 200 OK):
{
  "added": 3,
  "skipped_duplicates": 0,
  "active_phrases": 28,
  "took_ms": 1.18
}
POST

/v1/datasets/{id}/items:delete

Delete records by passing the matching item id and text. Sub-phrases shared with other records remain intact with decremented frequency.

// Request Payload:
{
  "ids": ["item_902"],
  "texts": ["Apple MacBook Pro 16 inch M3 Max"]
}
// Response (HTTP 200 OK):
{
  "deleted_docs": 2,
  "purged_phrases": 5,
  "active_phrases": 23,
  "took_ms": 0.31
}

Measured Performance

These numbers come from the internal engine benchmark suite in this repository (internal/engine/benchmark_test.go), run against an in-memory stress-test dataset of 100,000 raw ingested items (which deduplicate into ~38,000–45,000 unique indexed phrases and n-grams) across 5,000 varied queries (prefix, multi-word, typo, and non-Latin script). Note that while the self-serve Beta plan caps customer accounts at 2 datasets and 500 rows, this stress test exercises the underlying engine directly to verify capacity for high-volume custom deployments. Re-run it yourself with go test ./internal/engine/ -run TestStress100kDatasetMetrics -v.

Metric Value Notes
Median match time 0.031 ms Time to match one query against the index.
p95 match time 0.104 ms 95% of queries match faster than this.
p99 match time 0.139 ms Worst-case tail.
Ingestion throughput ~63,000 rows/sec Items indexed and deduplicated per second.
Memory per row ~0.31 KB Heap in use after ingesting 100,000 rows.
Snapshot load time ~250 ms Cold load of a compressed 3.9 MB snapshot.
Match time is measured on the server and does not include network round-trip time. The total time your browser sees will be higher, and it depends on where your users are. Every response includes a took_ms field so you can measure the real number on your own data.

Errors & Troubleshooting

Every error response uses the same shape. The error field explains what went wrong in plain language.

{
  "error": "Dataset not found"
}
Status Meaning What to do
400 The request is missing a required field or the payload is malformed. Check that dataset_id and q are both present and URL-encoded.
401 The API key is missing, malformed, or revoked. Send a valid key in the X-API-Key header. If you revoked it, issue a new one from the console.
403 The key is valid but not allowed to perform this action. Public keys can only read suggestions. Use your secret key for uploads and deletes.
404 The dataset does not exist for this account. Copy the dataset ID from the console. Datasets are scoped to the account that owns them.
429 Too many requests in a short window. Add a debounce of 100–200ms and send one request per keystroke burst, not one per keystroke.
402 The monthly suggestion quota for this account is used up. Wait for the monthly reset, or contact us to raise your limit.

Common issues

  • Suggestions come back empty. The most common cause is a typo that is more than two edits away, or a query shorter than two characters. Confirm the dataset has items with GET /v1/datasets/{id}/stats.
  • Requests fail in the browser but work in curl. You are probably sending the secret key from frontend code. Use the public key in the browser and keep the secret key on your server.
  • Every keystroke sends two requests. The browser is issuing a CORS preflight. Pass the public key as a ?key= query parameter instead of a header to make it a simple request.
  • Items upload but never appear in suggestions. The index is rebuilt asynchronously. Wait a second and retry, and check the dataset has an active phrase count above zero.

Copy-Paste Code Examples

Pre-filled with your live server host, active keys, and dataset.

Zero-Code CMS Integration Pre-filled with Live Credentials

WordPress, WooCommerce, Shopify & Webflow (1-Line Drop-In Widget)

No coding required. Automatically upgrades built-in WordPress (input[name="s"]), Shopify (input[name="q"]), and Webflow (input[name="query"]) search bars.

1 Copy your ready-to-paste 1-line script (your Public Key & Dataset ID are already filled in):
<script
  src="https://typosuggest.pial.digital/widget.js"
  data-key="ac_pub_YOUR_KEY"
  data-dataset="YOUR_DATASET_ID"
  defer
></script>
2 Follow the visual menu steps for your CMS (Zero Theme File Edits):
WordPress / Woo name="s"
  1. Open WP Admin → WPCode (Headers & Footers) or Elementor → Custom Code.
  2. Paste into the Header box and click Save.
  3. Automatically upgrades every input[name="s"] search bar.
Shopify name="q"
  1. Open Online Store → Themes → Edit code.
  2. In layout/theme.liquid, paste right above </head> & Save.
  3. Automatically upgrades input[name="q"] search bars.
Webflow / HTML type="search"
  1. Open Site Settings → Custom Code → Head Code.
  2. Paste the script & click Publish.
  3. Upgrades input[name="query"] or any type="search" input.
3 What the Default Widget UI Looks Like (Light Mode, Dark Mode & Live Try-It Box):

By default, widget.js automatically inherits your search input’s font and border radius. Below is how the built-in dropdown renders in Light Mode (default) and Dark Mode (data-theme="dark"), plus a live input you can type into right now:

Default Light UI Auto-inherits font
wrodpres name="s"
  • WordPress website
  • Modern WordPress website development
  • Custom WordPress website with Elementor
Dark Mode UI data-theme="dark"
macbok name="q"
  • Apple MacBook Pro 16-inch M3 Max
  • MacBook Air 15-inch M3 Chip
  • USB-C Hub for MacBook Pro
Try the live widget.js dropdown right here: Type wrodpres, macbok, or react to test the real widget in action.
<!-- Option A: Dark Mode or Custom Selector -->
<script src="https://typosuggest.pial.digital/widget.js" data-key="ac_pub_YOUR_KEY" data-dataset="YOUR_DATASET_ID" data-theme="dark" defer></script>

<!-- Option B: Headless Mode (Your Own Custom UI Renders the Results) -->
<script src="https://typosuggest.pial.digital/widget.js" data-key="ac_pub_YOUR_KEY" data-dataset="YOUR_DATASET_ID" data-headless="true" defer></script>
<script>
  document.addEventListener("typosuggest:results", function (e) {
    console.log("Suggestions for", e.detail.query, e.detail.suggestions);
    // Render e.detail.suggestions into your own theme modal or dropdown!
  });
</script>
React / Next.js <SearchAutocomplete /> Component
import React, { useState, useEffect, useRef } from 'react';

const DATASET_ID = 'YOUR_DATASET_ID';
const PUBLIC_KEY = 'ac_pub_YOUR_KEY'; // Safe for browsers: read-only scope
const API_BASE = 'https://typosuggest.pial.digital';

export default function SearchAutocomplete() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [error, setError] = useState(null);
  const requestSeq = useRef(0);

  useEffect(() => {
    const trimmed = query.trim();
    if (!trimmed) {
      setResults([]);
      setError(null);
      return;
    }

    const seq = ++requestSeq.current;
    const controller = new AbortController();

    const timer = setTimeout(async () => {
      try {
        // Passing ?key= in URL avoids browser CORS OPTIONS preflight
        const url = `${API_BASE}/v1/suggest?dataset_id=${encodeURIComponent(DATASET_ID)}&q=${encodeURIComponent(trimmed)}&limit=6&key=${encodeURIComponent(PUBLIC_KEY)}`;
        const res = await fetch(url, { signal: controller.signal });
        if (!res.ok) {
          const errBody = await res.json().catch(() => ({}));
          throw new Error(errBody.error || `HTTP ${res.status}`);
        }
        const data = await res.json();
        if (seq === requestSeq.current) {
          setResults(Array.isArray(data.suggestions) ? data.suggestions : []);
          setError(null);
        }
      } catch (err) {
        if (err.name === 'AbortError') return;
        if (seq === requestSeq.current) {
          console.error('Suggest error:', err);
          setError(err.message);
        }
      }
    }, 120);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  return (
    <div style={{ position: 'relative', width: '360px' }}>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products, services, news..."
        style={{ width: '100%', padding: '10px 14px', border: '1px solid #E5E2DC', borderRadius: '8px', outline: 'none' }}
      />
      {error && <div style={{ color: '#b91c1c', fontSize: '12px', marginTop: '4px' }}>{error}</div>}
      {results.length > 0 && (
        <ul style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: '#fff', border: '1px solid #E5E2DC', borderRadius: '8px', listStyle: 'none', margin: '4px 0 0 0', padding: '4px 0', boxShadow: '0 4px 12px rgba(0,0,0,0.05)', zIndex: 50 }}>
          {results.map((item) => (
            <li key={item.text} style={{ padding: '8px 14px', cursor: 'pointer', borderBottom: '1px solid #F4F3EE', fontSize: '13px' }}>
              {item.text}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
Vanilla HTML & JavaScript Search Dropdown
const API_BASE = 'https://typosuggest.pial.digital';
const DATASET_ID = 'YOUR_DATASET_ID';
const PUBLIC_KEY = 'ac_pub_YOUR_KEY';

let activeController = null;

async function queryAutocomplete(keyword) {
  const trimmed = keyword.trim();
  if (!trimmed) return [];

  if (activeController) activeController.abort();
  activeController = new AbortController();

  const url = `${API_BASE}/v1/suggest?dataset_id=${encodeURIComponent(DATASET_ID)}&q=${encodeURIComponent(trimmed)}&limit=5&key=${encodeURIComponent(PUBLIC_KEY)}`;
  const res = await fetch(url, { signal: activeController.signal });
  if (!res.ok) {
    const errBody = await res.json().catch(() => ({}));
    throw new Error(errBody.error || `HTTP ${res.status}`);
  }
  const data = await res.json();
  return Array.isArray(data.suggestions) ? data.suggestions : [];
}
Node.js / Express Ingestion Hook
// Server-side only: keep your ac_sec_... key in environment variables
async function indexProduct(product) {
  const API_BASE = 'https://typosuggest.pial.digital';
  const datasetId = process.env.TYPOSUGGEST_DATASET_ID;
  const secretKey = process.env.TYPOSUGGEST_SECRET_KEY; // ac_sec_...

  const res = await fetch(`${API_BASE}/v1/datasets/${encodeURIComponent(datasetId)}/items:add`, {
    method: 'POST',
    headers: {
      'X-API-Key': secretKey,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      items: [
        { id: String(product.id), text: product.title, group: product.category || 'product', count: 1 }
      ]
    }),
    signal: AbortSignal.timeout(10000)
  });

  const payload = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(`TypoSuggest ingest failed (${res.status}): ${payload.error || res.statusText}`);
  }
  return payload; // { added, skipped_duplicates, active_phrases, took_ms }
}
Python Ingestion & Search (requests)
import os
import requests

API_BASE = "https://typosuggest.pial.digital"
DATASET_ID = "YOUR_DATASET_ID"
SECRET_KEY = os.environ.get("TYPOSUGGEST_SECRET_KEY", "ac_sec_YOUR_KEY")

res = requests.post(
    f"{API_BASE}/v1/datasets/{DATASET_ID}/items:add",
    headers={"X-API-Key": SECRET_KEY, "Content-Type": "application/json"},
    json={
        "items": [
            {"id": "sku-1", "text": "Apple MacBook Pro 16 inch M3 Max", "group": "product", "count": 25},
            {"id": "sku-2", "text": "Modern WordPress Website Development", "group": "gig", "count": 10},
        ]
    },
    timeout=5.0,
)
if not res.ok:
    raise RuntimeError(f"Ingest failed ({res.status_code}): {res.text}")

payload = res.json()
print(f"Added {payload.get('added', 0)} items ({payload.get('active_phrases', 0)} active phrases)")
Go HTTP Client (net/http)
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"time"
)

type Suggestion struct {
	Text      string  `json:"text"`
	Group     string  `json:"group"`
	Score     float64 `json:"score"`
	Frequency uint32  `json:"frequency"`
}

type SuggestResponse struct {
	Query       string       `json:"query"`
	TookMs      float64      `json:"took_ms"`
	Suggestions []Suggestion `json:"suggestions"`
}

func Suggest(ctx context.Context, query string) (*SuggestResponse, error) {
	client := &http.Client{Timeout: 5 * time.Second}
	u, _ := url.Parse("https://typosuggest.pial.digital/v1/suggest")
	q := u.Query()
	q.Set("dataset_id", "YOUR_DATASET_ID")
	q.Set("q", query)
	q.Set("limit", "8")
	u.RawQuery = q.Encode()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("X-API-Key", "ac_pub_YOUR_KEY")

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		var apiErr struct {
			Error string `json:"error"`
		}
		_ = json.NewDecoder(resp.Body).Decode(&apiErr)
		return nil, fmt.Errorf("typosuggest HTTP %d: %s", resp.StatusCode, apiErr.Error)
	}

	var out SuggestResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	return &out, nil
}
cURL Query Example
curl "https://typosuggest.pial.digital/v1/suggest?dataset_id=YOUR_DATASET_ID&q=macbook&limit=5" \
  -H "X-API-Key: ac_pub_YOUR_KEY"