Reference
API
Two endpoints. The request shape mirrors what most agent code already speaks, so adopting it is usually a base URL change.
Authentication
Send your key either way. Both work, on every endpoint.
authorization: Bearer twl_...
x-api-key: twl_...
Keys carry one of two scopes. A search key can spend quota and nothing
else, which is what you deploy into agents and containers. An admin key
can also mint and revoke keys and read usage. The raw key is shown once, at creation;
only its hash is stored, so it cannot be recovered.
POST /v1/search
{
"query": "what changed in the EU AI act", // required
"search_depth": "basic", // "basic" | "advanced"
"topic": "general", // "general" | "news"
"max_results": 5,
"include_answer": false,
"include_raw_content": false,
"include_domains": [],
"exclude_domains": [],
"time_range": "week" // day | week | month | year
} Depth is the cost dial. basic returns provider snippets
and fetches no pages. advanced fetches every result, extracts clean
Markdown, and ranks passages. That is roughly an order of magnitude in both latency
and cost. Asking for an answer implies the full pipeline, because there is nothing to
answer from otherwise.
Topic selects the sources. general searches the open
web; news searches news indexes, which return recency and a
published_date that general results usually lack. Sources that do not
serve the topic you asked for come back as skipped, so the array is the
same shape either way.
Response
{
"query": "...",
"answer": "The Act entered into force ...", // null unless include_answer
"answer_citations": ["https://..."],
"results": [
{
"title": "...",
"url": "https://example.com/article",
"content": "# Heading\n\nClean markdown ...",
"score": 0.03175,
"published_date": "Tue, 12 Aug 2026 09:14:00 GMT",
"providers": ["web_1", "web_2"],
"fetched": true,
"fetch_error": null
}
],
"providers": [
{ "provider": "web_1", "status": "ok", "resultCount": 10, "elapsedMs": 304 },
{ "provider": "web_2", "status": "circuit_open", "resultCount": 0,
"error": "breaker open until ...", "elapsedMs": 0 },
{ "provider": "news_1", "status": "skipped", "resultCount": 0,
"error": "does not serve topic \"general\"", "elapsedMs": 0 },
{ "provider": "news_2", "status": "skipped", "resultCount": 0,
"error": "does not serve topic \"general\"", "elapsedMs": 0 }
],
"response_time": 3.1
} Always read providers
Sources go down, rate-limit, and come back. This array is how you tell a complete
answer from a degraded one without guessing, and it is the reason an empty
results array is never ambiguous.
Sources are identified as web_1, web_2, news_1
and news_2. These ids are stable: an id keeps meaning the same slot in
the mix, so you can safely alert on one going quiet. Which index sits behind an id is
ours to change, and doing so is not a breaking change for you.
| status | Meaning |
|---|---|
ok | Returned results. |
failed | Tried and failed. error says why. |
circuit_open | Out of rotation after repeated failures. |
disabled | Switched off for your org. |
skipped | Cannot serve this topic. |
An empty results array only ever means the web had nothing to say. If
every provider failed you get a 502, never a 200 with nothing in it.
The providers field on each result is a quality signal in its own right:
a URL listed by two engines was ranked independently by both. Results are fused by
rank, so agreement lifts a page above any single provider's favourite.
POST /v1/extract
The same pipeline without the retrieval stage, for URLs you already have.
{ "urls": ["https://example.com/a", "https://example.com/b"] } {
"results": [
{ "url": "...", "title": "...", "content": "# ...", "source": "fetch" }
],
"failed": [
{ "url": "...", "error": "disallowed by robots.txt" }
],
"response_time": 1.2
} URLs are canonicalised and deduplicated before anything is charged, so the same page with three different tracking parameters costs one extraction. Billing is per URL, and URLs that produce nothing are refunded. If nothing at all could be extracted you get a 502, because an empty array would read as "these pages are blank".
Errors
| Status | When |
|---|---|
| 400 | Malformed request. details carries the field errors. Fail loud; this is a bug in the caller, not an empty result. |
| 401 | Missing, unknown, or revoked key. |
| 403 | Valid key, insufficient scope. |
| 429 | Rate limit or quota. Rate limits carry retry-after. |
| 502 | Every provider failed, or nothing could be extracted. |
| 503 | A required capability is not configured. |
A missing capability returns 503 naming it rather than quietly omitting it. A response that looks successful while leaving out the thing you asked for sends you debugging the wrong layer.
Notes for agent authors
- Start with
basicdepth. Most agent queries are answered by titles and snippets, and it is roughly ten times cheaper and faster. include_answeris not free. It spends the answer meter and adds a model round trip. If you are already running a model, passresultsto it instead.- Retrieved content is untrusted. We fence it and constrain the answer schema, but if you feed
contentinto your own prompt, treat it as hostile input. A search API is an attractive injection target because the attacker only has to rank for a query you run. - Cite from
answer_citations. Those URLs are validated against the sources actually supplied, so an injected or hallucinated index is dropped before it reaches you.
Base URL
https://api.trawlia.co