Skip to content
unzoi docs
Search and navigation
Start here
REST API
MCP
Limits and plans
Agent clients
SDKs
Guides

Rust

[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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 key stops at its monthly credits, and this call could cost more
    /// than are left. Shares a status code with the per-minute limit, and
    /// unlike it, retrying does not help until the period resets.
    #[error("out of monthly credits; resets in {0}s")]
    OutOfCredits(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 => {
                    // Three conditions share this status, and the body's code says
                    // which. Out of credits for the month is the one not to retry.
                    let wait = header("retry-after")
                        .and_then(|v| v.parse().ok())
                        .unwrap_or(1);
                    let refusal: serde_json::Value = response.json().await.unwrap_or_default();
                    if refusal["code"] == "insufficient_credits" {
                        return Err(Error::OutOfCredits(wait));
                    }
                    // retry-after is when this call's credits refill, and it is
                    // accurate — exponential backoff here would idle on an
                    // allowance already paid for.
                    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 credits_per_minute: u32,
    pub credits_limit: u64,
    pub credits_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.

Why thiserror instead of a boxed error

A library crate returning Box<dyn std::error::Error> forces every caller to downcast if they want to branch on which failure happened, which is exactly the branch OutOfCredits exists for — a caller that hits it wants to surface a distinct message and stop, not retry. thiserror derives Display and std::error::Error from the enum variants directly, so Error::OutOfCredits(_) is matchable with an ordinary match at the call site rather than an is::<T>() downcast. anyhow is the better choice one layer up, in a binary that only ever logs and exits — it is deliberately not used here because this is a library other crates depend on, and a caller three crates away should not need to know this one uses anyhow internally to handle a specific variant.

Testing without the real API

wiremock or a hand-rolled hyper server bound to 127.0.0.1:0 stands in for BASE in a #[tokio::test] — point a test-only constructor at the mock's address and assert on the same two behaviours the Go and Python pages test for their clients: a 429 whose body carries "code": "insufficient_credits" returns Error::OutOfCredits immediately rather than looping, and a 429 with credits left returns successfully within roughly a second rather than backing off exponentially. Assert on wall-clock time with tokio::time::pause() and tokio::time::advance() rather than a real sleep in the test — otherwise the retry test for the out-of-credits path is the slowest test in the suite for no reason, since that path should return without ever sleeping at all.

Why multi-threaded Tokio, not current-thread

rt-multi-thread is the pragmatic default for anything that also does other work concurrently with a call to this client — a web service handling other routes, an agent loop running other tools. A single-threaded, current_thread runtime works fine for a script that does nothing but call Unzoi and print the result, and starts up marginally faster, but the moment the same binary tokio::spawns a second task — logging, a metrics exporter, another API client — a current-thread runtime serializes it behind whatever this client is awaiting. The type signatures above do not change either way; only the #[tokio::main] attribute's flavor does.

Why rustls, not native-tls

The Cargo.toml at the top disables reqwest's default features and opts back into only json and rustls-tls, rather than taking the default OpenSSL-backed TLS. Two concrete reasons, not a general preference: a pure-Rust TLS stack cross-compiles cleanly to a scratch or distroless container without also vendoring OpenSSL headers for the target platform, and it removes an entire class of build failure — a missing libssl-dev on a fresh CI image — that has nothing to do with this crate and everything to do with whichever base image someone picked. The cost is that rustls does not read the operating system's certificate store by default; if this client ever needs to trust a corporate proxy's internal certificate authority, that certificate has to be added explicitly via rustls-native-certs rather than picked up automatically the way OpenSSL would.