Rust
[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
thiserror = "2" The client
use serde::Deserialize;
use std::time::Duration;
const BASE: &str = "https://api.unzoi.com";
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The monthly allowance is used up on a key that stops at its quota.
/// Shares a status code with the rate limit, and unlike it, retrying does
/// not help until the period resets.
#[error("monthly quota exhausted; resets in {0}s")]
QuotaExhausted(u64),
#[error("unzoi: {0}")]
Status(reqwest::StatusCode),
#[error(transparent)]
Http(#[from] reqwest::Error),
}
pub struct Unzoi {
http: reqwest::Client,
key: String,
}
impl Unzoi {
pub fn new(key: impl Into<String>) -> Result<Self, Error> {
Ok(Self {
http: reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()?,
key: key.into(),
})
}
async fn get<T: serde::de::DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<T, Error> {
for attempt in 0..6u32 {
let response = self
.http
.get(format!("{BASE}{path}"))
.header("x-api-key", &self.key)
.query(params)
.send()
.await?;
let header = |name: &str| {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
match response.status() {
reqwest::StatusCode::TOO_MANY_REQUESTS => {
// Two conditions share this status; only one is transient.
if header("x-quota-remaining").as_deref() == Some("0") {
let reset = header("retry-after")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
return Err(Error::QuotaExhausted(reset));
}
// retry-after is 1 and it is accurate — the bucket refills
// continuously, so exponential backoff here would idle on
// an allowance already paid for.
let wait = header("retry-after")
.and_then(|v| v.parse().ok())
.unwrap_or(1);
tokio::time::sleep(Duration::from_secs(wait)).await;
}
// No shard answered. Narrowing the range helps more than waiting.
reqwest::StatusCode::SERVICE_UNAVAILABLE if attempt < 3 => {
tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
}
status if status.is_client_error() || status.is_server_error() => {
return Err(Error::Status(status));
}
_ => return Ok(response.json().await?),
}
}
Err(Error::Status(reqwest::StatusCode::TOO_MANY_REQUESTS))
}
pub async fn search(&self, params: &[(&str, &str)]) -> Result<SearchResponse, Error> {
self.get("/search", params).await
}
pub async fn stories(&self, params: &[(&str, &str)]) -> Result<StoriesResponse, Error> {
self.get("/stories", params).await
}
pub async fn account(&self) -> Result<Account, Error> {
self.get("/account", &[]).await
}
} Types
#[derive(Debug, Deserialize)]
pub struct Article {
pub id: String,
pub title: Option<String>,
pub url: Option<String>,
pub source: Option<String>,
pub language: Option<String>,
pub published_at: Option<String>,
pub story_id: Option<String>,
#[serde(default)]
pub topics: Vec<String>,
#[serde(default)]
pub organizations: Vec<String>,
#[serde(default)]
pub countries: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Story {
pub story_id: String,
pub count: usize,
pub outlets: usize,
pub title: Option<String>,
pub url: Option<String>,
#[serde(default)]
pub sources: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct SearchResponse {
pub total: usize,
pub total_relation: String,
pub has_more: bool,
pub results: Vec<Article>,
/// How far back this plan may query; `None` = the full archive.
pub history_days: Option<u32>,
/// Whether a `from` you supplied was moved forward to that boundary.
pub from_clamped: bool,
}
#[derive(Debug, Deserialize)]
pub struct StoriesResponse {
pub total: usize,
pub has_more: bool,
pub stories: Vec<Story>,
pub history_days: Option<u32>,
pub from_clamped: bool,
}
#[derive(Debug, Deserialize)]
pub struct Account {
pub plan: String,
pub rate_per_min: u32,
pub quota: u64,
pub quota_remaining: Option<u64>,
pub history_days: Option<u32>,
} Using it
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let unzoi = Unzoi::new(std::env::var("UNZOI_KEY")?)?;
let result = unzoi
.stories(&[
("q", "semiconductor export controls"),
("from", "2026-08-01"),
("limit", "10"),
])
.await?;
// A plan boundary and a corpus with no coverage look identical without this.
if result.from_clamped {
eprintln!(
"window narrowed to the last {:?} days by your plan",
result.history_days
);
}
for story in &result.stories {
println!(
"{:>3} articles / {:>2} outlets {}",
story.count,
story.outlets,
story.title.as_deref().unwrap_or("(untitled)")
);
}
Ok(())
} Calling MCP instead
The official Rust SDK is rmcp — the same
crate this API's own MCP server is built on, so a client written with it is talking to a server that speaks exactly
its dialect.