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));