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 a handful of endpoints that is the smaller thing to maintain.
The client
const BASE = "https://api.unzoi.com";
export class OutOfCredits extends Error {
constructor(readonly resetSeconds: number) {
super(`out of monthly credits; resets in ${resetSeconds}s`);
}
}
/** Every error body is JSON with a stable code — switch on that, not on the status. */
export class ApiError extends Error {
constructor(readonly status: number, readonly code: string, detail: string) {
super(`${status} ${code}: ${detail}`);
}
}
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) {
// Three conditions share this status, and the body's code says which.
// Out of credits for the month is not transient.
const refusal = await response.clone().json().catch(() => ({}));
if (refusal.code === "insufficient_credits") {
throw new OutOfCredits(Number(response.headers.get("retry-after") ?? 0));
}
if (attempt < 5) {
// retry-after is when this call's credits refill, usually a second
// or two. 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) {
const body = (await response.json()) as { code: string; detail: string };
throw new ApiError(response.status, body.code, body.detail);
}
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); }
story(id: string, limit = 20) { return this.get<StoryDetail>(`/stories/${encodeURIComponent(id)}`, { limit }); }
entities(params: { q: string; type: string; limit?: number; from?: string; to?: string }) { return this.get<EntityMatches>("/entities", 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`);
}
// So do a degraded index and a quiet news day.
if (result.partial || result.coverage !== "complete") {
console.warn(`incomplete answer (${result.coverage}); do not treat a short list as the answer`);
}
for (const story of result.stories) {
console.log(`${story.count} articles / ${story.outlets} outlets — ${story.title}`);
}
// One call for the whole event, no re-search.
const detail = await unzoi.story(result.stories[0].story_id, 5);
console.log(detail.first_seen, detail.last_seen, detail.articles.map((a) => a.source)); 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 },
});
// structuredContent is the typed payload, the same JSON /stories returns, in
// the shape the tool's outputSchema declares; the text block holds the same
// JSON for older clients. Read one, never both.
const body = result.structuredContent ?? JSON.parse(result.content[0].text);
console.log(body); This client has no timeout, and that is a gap
Every other language on this site sets one: the Go client's http.Client carries a 30-second
Timeout, the Rust client's reqwest::Client is built with the same, and the Python client
passes timeout=30.0 to httpx. Plain fetch has no default timeout at all — a
connection that hangs rather than closes waits forever, and the retry loop above never gets the chance to run,
because it only sees responses, not a stall before one arrives. Add one explicitly:
const response = await fetch(url, {
headers: { "x-api-key": this.key },
signal: AbortSignal.timeout(30_000),
}); AbortSignal.timeout throws an AbortError rather than returning a response, so it needs
its own catch around the loop rather than falling into the status-code branches above — a hung
connection and a 503 are different failures and are worth logging differently.
Throwing versus returning a result
This client throws — OutOfCredits and the generic Error both propagate as exceptions,
which matches how fetch itself behaves for a network failure. The alternative, a Rust-style
{ ok: true, value } | { ok: false, error } return type, reads better at a call site that has to
handle a quota exhaustion as an expected outcome rather than an exceptional one — a scheduled job that should log
and skip a cycle rather than crash. If you adopt that shape, keep the boundary at your own wrapper rather than
inside this class: letting fetch's own thrown errors (a DNS failure, an aborted signal) escape
un-normalized while your own errors return a result object is the inconsistency that actually causes bugs, not
which style you pick.
Node, and the edge runtimes
Nothing above is Node-specific — fetch, URL and AbortSignal are all
available in Cloudflare Workers, Vercel's Edge Runtime and Deno without modification, which is why this client is a
plain class rather than something built on a Node-only HTTP library. The one thing worth checking per platform is
the default fetch timeout the runtime imposes independently of yours — several edge platforms cut a
request off at a fixed wall-clock limit regardless of AbortSignal, which matters if your own timeout
above is set longer than the platform allows.
Adding jitter once more than one process is retrying
The retry loop above waits exactly retry-after seconds, which is correct for one process but produces
a thundering herd the moment several instances of the same job hit the same rate limit at the same moment — a
deploy that starts ten workers simultaneously, say. Each one reads the same retry-after, sleeps the
same duration, and retries in the same instant, which reproduces the exact contention that caused the first
429.
const base = Number(response.headers.get("retry-after") ?? 1) * 1000;
const jitter = base * (0.5 + Math.random() * 0.5); // 50%-150% of the header value
await sleep(jitter);
Jitter only helps the multi-process case — a single script gets no benefit from randomizing a wait against itself
— so add it once you know this client runs in more than one place at once, not before. Keep the floor at 50% of
the header's value rather than allowing it down to zero: retry-after is a real signal about when the
bucket refills, and jittering below it just reintroduces the same 429 earlier than necessary.