Snaga Search API

Web search for AI agents: links and what each page says about itself, from our own index of the web.

Everything on this page, as one machine-readable document: openapi.json. Point your client generator at it, or fetch it and read it.

Base URLhttps://search.snaga.ai
Your keyAuthorization: Bearer ws_…
One searchten results by default, up to fifty
The pricethe free plan gives 5,000 requests a month; prepaid credit is $1 per 1,000 requests to start, and less as the month goes on
Speedmedian 451 ms, p99 451 ms, from 1 search

Getting a key

A key is a short string that begins with ws_. It is shown once, when it is created, and never again, so keep it where you keep secrets: a request with your key is billed to you.

Write to [email protected] and we will make you a key: for the free plan — 5,000 requests a month, 60 a minute — or for prepaid credit at $1 per 1,000 requests, 600 a minute. The price falls as the month goes on: $0.80 per 1,000 requests after the first million, $0.60 after ten million.

Authentication

Send your key with every request:

Authorization: Bearer ws_k1a2b3c4d_…

X-API-Key: ws_… works as well. Errors are never billed.

GET /v1/search?q=<query>&limit=<1–50>&lang=<code>&site=<domain>
ParameterMeaning
qRequired. The query, up to 500 characters.
limitOptional, ten by default. Results, 1–50.
langOptional. Only pages in this language: ISO 639-1, e.g. en, uk, de.
siteOptional. Only pages of this registered domain, e.g. python.org.

The query understands:

Write the words a page would use. Many words are fine: pages with all of them come first, then pages missing one, then pages with at least half.

A query of operators alone — site:python.org on its own — has no words to look for, and finds nothing. The answer says so: it carries no_words, so an agent can tell an empty answer from a question with no words in it. Put a word beside the operator.

curl -s "https://search.snaga.ai/v1/search?q=asyncio+task+groups" \
  -H "Authorization: Bearer $KEY"

An answer holds the query that was asked for, the results, and the took_ms the search took, in milliseconds — with the fraction, when the search was that quick.

An answer:

{
  "query": "asyncio task groups",
  "took_ms": 11.8,
  "results": [
    {
      "url": "https://docs.python.org/3/library/asyncio-task.html",
      "title": "Coroutines and Tasks — Python 3.14 documentation",
      "description": "This section outlines high-level asyncio APIs to work with coroutines and Tasks.",
      "site": "python.org",
      "site_rank": 101,
      "lang": "en",
      "crawled": "2026-09-04",
      "score": 41.7,
      "matched": ["asyncio", "task", "groups"]
    }
  ]
}
FieldMeaning
urlThe page; its canonical URL when the page names one on the same host.
title, descriptionWhat the page says about itself: its title and meta description.
siteThe site the page belongs to, e.g. python.org, or user.github.io on a shared platform.
site_rankThe site's position in our ranking of how much the web links to it; lower is more linked-to. Missing for unranked sites.
langThe page's language, when known.
crawledWhen the page was crawled, YYYY-MM-DD.
scoreRelevance, comparable only within one answer.
matchedQuery words the page has — in its title, description or text, as written or as another form of the word.
missingDistinctive query words the page lacks; shown only for partial matches.
excerptThe sentences of the page's text that best match the query.

At most two results come from one site, and the same title on the same site counts once.

From Python

import json, urllib.parse, urllib.request

key = "ws_…"
url = "https://search.snaga.ai/v1/search?" + urllib.parse.urlencode(
    {"q": "asyncio task groups", "limit": 5})
request = urllib.request.Request(url, headers={"Authorization": "Bearer " + key})
with urllib.request.urlopen(request) as response:
    for hit in json.load(response)["results"]:
        print(hit["title"], hit["url"])

From JavaScript

const response = await fetch(
  "https://search.snaga.ai/v1/search?q=asyncio+task+groups&limit=5",
  { headers: { Authorization: `Bearer ${key}` } });
const { results } = await response.json();
for (const { title, url, description } of results) {
  console.log(title, url);
}

The whole API, in one document a code generator can read: openapi.json.

Try it

Here is the engine, without a key, so you can see an answer before you write a line of code. This is the endpoint the public page uses: ten results at a time. The keyed one returns up to fifty and adds each result's score and the query words it matched. Both of them say which words a result lacks.

The box below draws the answer the way the public page draws it: the same text under the title, the same words of the query marked in it, and the same note of the word a result does not have. The same search says the same thing on every page.

The keyless endpoint is GET /site/search. It is free, and it is rate limited to thirty searches a minute from one address. Its answer carries the same rate headers, so a client can pace itself; the answer below shows how many are left.

GET /site/search?q=<query>&lang=<code>&news=1&page=<1–5>
ParameterMeaning
qRequired. The query, up to 500 characters, with the same syntax as above.
langOptional. Only pages in this language.
newsOptional. Only the pages we crawled in the last fourteen days, when 1.
pageOptional. Which ten results to return, from one to five. The order does not change from page to page, so a client can walk the pages without missing one.

The count it prints says how many results the engine found. When it says at least fifty, the engine stopped at fifty and there are more; the answer carries the same fact in its total and capped fields.

The answer carries the query, the page you are on and the pages there are, the total number of results, whether they were capped — more than the fifty the engine returns — the server_time_ms the search took, in whole milliseconds, and the results: each one has the url, the title, the description, the site it belongs to, the excerpt — the sentences of the page's text that match the query — the crawled day, and the missing — the query words the page does not have, when the result is a partial match. A query of operators alone comes back with no_words and finds nothing.

Search to see an answer.

    The raw answer
    
          
        

    Usage and balance

    GET /v1/usage

    This is free and doesn't count against your rate. The answer holds the key, the plan it is on, the rate_per_min it is limited to, the month it counts in, the requests_this_month and the spent_usd — and, on the free plan, the free_requests_left, or, on prepaid credit, the credit_left_usd.

    {
      "key": "k1a2b3c4d",
      "plan": "payg",
      "rate_per_min": 600,
      "month": "2026-10",
      "requests_this_month": 1520,
      "credit_left_usd": 8.48,
      "spent_usd": 1.52
    }

    A free key shows free_requests_left instead of credit_left_usd.

    A search answer carries these headers:

    HeaderMeaning
    X-RateLimit-Limit, X-RateLimit-RemainingRequests per minute, and how many are left now.
    X-Quota-Limit, X-Quota-RemainingFree plan: the month's requests and what's left after this one.
    X-Credit-Remaining-UsdPay as you go: the credit left after this request.

    A search without a key carries only the rate headers. A key on the free plan carries the quota headers and not the credit one; a key on prepaid credit carries the credit header and not the quota ones.

    Errors

    Errors are JSON, {"error": "<code>", "message": "<what to do>"}, and are never billed.

    StatusErrorMeaning
    400bad_requestThe query is missing or too long: q in the keyed search, query in Ollama's.
    401unauthorizedNo key, or an unknown or revoked one.
    402quota_exceededThe free plan's requests for this month are used up. The message says when they renew.
    402credit_exhaustedNot enough prepaid credit for another request.
    429rate_limitedOver your requests per minute. Wait Retry-After seconds.
    500search_failedOur fault. Retrying is safe.

    MCP

    The API is also a Model Context Protocol server at POST /mcp, using the streamable HTTP transport (protocol versions 2025-06-18, 2025-03-26 and 2024-11-05). It has the tool web_search, with these arguments:

    When the server fetches, it has a second tool, web_fetch: one argument, url, and an answer with the page's title, its content as Markdown and the links it holds — the same fetch as POST /api/web_fetch below. A fetch is a request to that page's own server, and it follows the rules our crawler follows: /robots.txt is read first, and a page is not fetched again for fifteen minutes.

    The tool returns the results as text for the model, and as structuredContent.results: the fields of the answer above — url, title, description, site, site_rank, lang, crawled, matched and missing — and in place of score the content of the page that matches the query. A query of filters alone has no words to look for: the text says so, and structuredContent.no_words is there for a client that reads the fields. A tool call is billed like a search. initialize and tools/list aren't billed, but they count toward the rate limit.

    {
      "mcpServers": {
        "web-search": {
          "type": "http",
          "url": "https://search.snaga.ai/mcp",
          "headers": { "Authorization": "Bearer ws_…" }
        }
      }
    }

    Ollama's endpoints

    Two endpoints have the shape Ollama gives them, so a client written for Ollama can be pointed here instead: the arguments are the same, the answers are the same, and only the address changes. What comes back is from our own index of the web rather than from a search engine that sells its ranking to anyone who asks.

    EndpointInOut
    POST /api/web_search{"query", "max_results"}{"results": [{"title", "url", "content"}]}, content being what the page says that matches the query
    POST /api/web_fetch{"url"}{"title", "content", "links"}, the page's main content as Markdown

    The search takes query — it is required — and max_results, from one to fifty and five by default. A query of filters alone has no words to look for, and the answer says so in no_words, beside the results.

    The index

    GET /v1/status

    It is free. The answer holds pages — how many pages the index holds — source — where they came from — and languages: the code of each language and the number of pages in it, most first.

    Your results and your queries

    People can search without a key at search.snaga.ai: the same engine, 30 searches a minute per address, ten results at a time.

    And SnagaBot is our crawler: what it fetches, the rules it follows, and how to limit or block it. To have pages removed from the index, write to [email protected].