Skip to content
unzoi docs

TypeScript

Types, from the spec

npx openapi-typescript https://api.unzoi.com/openapi.json -o src/unzoi.d.ts

Generated types, hand-written transport. For six GET endpoints that is the smaller thing to maintain.

The client

const BASE = "https://api.unzoi.com";

export class QuotaExhausted extends Error {
  constructor(readonly resetSeconds: number) {
    super(`monthly quota exhausted; resets in ${resetSeconds}s`);
  }
}

export class Unzoi {
  constructor(private key: string) {}

  async get<T>(path: string, params: Record<string, string | number | undefined> = {}): Promise<T> {
    const url = new URL(BASE + path);
    for (const [k, v] of Object.entries(params)) {
      if (v !== undefined) url.searchParams.set(k, String(v));
    }

    for (let attempt = 0; ; attempt++) {
      const response = await fetch(url, { headers: { "x-api-key": this.key } });

      if (response.status === 429) {
        // Two conditions share this status and only one is worth waiting for.
        // The quota is not transient: retrying burns the rest of your day.
        if (response.headers.get("x-quota-remaining") === "0") {
          throw new QuotaExhausted(Number(response.headers.get("retry-after") ?? 0));
        }
        if (attempt < 5) {
          // retry-after is 1: the rate bucket refills continuously, so a
          // second is genuinely enough. Do not back off exponentially here.
          await sleep(Number(response.headers.get("retry-after") ?? 1) * 1000);
          continue;
        }
      }

      // The query tier could not answer. Deliberately not an empty result set —
      // narrowing the time range makes this likelier to succeed.
      if (response.status === 503 && attempt < 3) {
        await sleep(2 ** attempt * 1000);
        continue;
      }

      if (!response.ok) {
        throw new Error(`${response.status} ${await response.text()}`);
      }
      return response.json() as Promise<T>;
    }
  }

  search(params: SearchParams) { return this.get<SearchResponse>("/search", params); }
  stories(params: SearchParams) { return this.get<StoriesResponse>("/stories", params); }
  headlines(params: SearchParams) { return this.get<SearchResponse>("/top-headlines", params); }
  article(id: string) { return this.get<ArticleDetail>(`/doc/${encodeURIComponent(id)}`); }
  related(id: string, limit = 10) { return this.get<SearchResponse>(`/similar/${encodeURIComponent(id)}`, { limit }); }
  account() { return this.get<AccountStatus>("/account"); }
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

Using it

const unzoi = new Unzoi(process.env.UNZOI_KEY!);

const result = await unzoi.stories({
  q: "semiconductor export controls",
  from: "2026-08-01",
  limit: 10,
});

// A plan boundary and a corpus with no coverage look identical without this.
if (result.from_clamped) {
  console.warn(`window narrowed to the last ${result.history_days} days by your plan`);
}

for (const story of result.stories) {
  console.log(`${story.count} articles / ${story.outlets} outlets — ${story.title}`);
}

Paging

async function* walk(unzoi: Unzoi, params: SearchParams) {
  let offset = 0;
  for (;;) {
    // 100 per page rather than 10: each page is a billed request.
    const page = await unzoi.search({ ...params, offset, limit: 100 });
    yield* page.results;
    if (!page.has_more) return;
    offset += 100;
  }
}

for await (const article of walk(unzoi, { q: "lithium supply", from: "2026-08-01" })) {
  console.log(article.source, article.title);
}

Calling MCP instead

If a model is choosing the queries rather than your code, use the MCP endpoint with the official SDK — the tool descriptions do the work this client's method names are doing here.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(new URL("https://api.unzoi.com/mcp"), {
  requestInit: { headers: { "x-api-key": process.env.UNZOI_KEY! } },
});

const client = new Client({ name: "my-app", version: "1.0.0" });
await client.connect(transport);

const result = await client.callTool({
  name: "list_stories",
  arguments: { q: "port congestion", limit: 5 },
});
// One content block; its text is the same JSON /stories returns.
console.log(JSON.parse(result.content[0].text));