Skip to content
unzoi docs

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.