A SERP API call from Rust is a blocking reqwest POST with an X-API-Key header, serde for the JSON, and a small loop for pagination. Two crates, about seventy lines, no async runtime needed if you're just scripting a handful of queries.
The request you're making
POST https://api.serpbase.dev/google/search with a JSON body: q is required; hl (defaults to en), gl (defaults to us), page (1-based, defaults to 1) and device (default / pc / mobile, search endpoint only) are optional. One successful request costs 1 credit. The response envelope carries status, request_id, credits_charged and search_type, plus the organic array.
Add the two crates:
[dependencies]
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
The client
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct SearchRequest<'a> {
q: &'a str,
hl: &'a str,
gl: &'a str,
page: u32,
device: &'a str,
}
#[derive(Deserialize, Debug)]
struct OrganicItem {
rank: u32,
title: String,
link: String,
#[serde(default)]
snippet: String,
#[serde(default)]
date: String,
}
#[derive(Deserialize, Debug)]
struct SearchResponse {
status: i64,
#[serde(default)]
error: String,
#[serde(default)]
request_id: String,
#[serde(default)]
credits_charged: f64,
#[serde(default)]
organic: Vec<OrganicItem>,
}
fn search(
client: &reqwest::blocking::Client,
key: &str,
query: &str,
page: u32,
) -> Result<SearchResponse, Box<dyn std::error::Error>> {
let body = SearchRequest {
q: query,
hl: "en",
gl: "us",
page,
device: "default",
};
let data: SearchResponse = client
.post("https://api.serpbase.dev/google/search")
.header("X-API-Key", key)
.json(&body)
.send()?
.json()?;
if data.status != 0 {
return Err(format!(
"status={} error={} request_id={}",
data.status, data.error, data.request_id
)
.into());
}
Ok(data)
}
The endpoint shape above, including the status convention (0 means success) and the field names, comes from the SerpBase docs for the search endpoint. I only map the fields I need: rank (1-based position in the response), title, link, and optionally snippet and date. Everything else in the payload — url, display_url, sitelinks, and friends — is ignored by serde unless you add it to the struct.
Two details worth copying:
-
#[serde(default)]on optional fields. The docs marksnippet,dateand friends as optional; without the attribute, a single missing key would fail deserialization for the whole response. -
Check
status, not just HTTP. A transport-level failure gives you a non-2xx status; API-level problems (invalid key, insufficient credits, rate limit) still return JSON with a numericstatusand anerrorstring, so branch on the body.
Paginate and print
fn main() -> Result<(), Box<dyn std::error::Error>> {
let key = std::env::var("SERPBASE_API_KEY")?;
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
for page in 1..=2 {
let data = search(&client, &key, "rust reqwest example", page)?;
if data.organic.is_empty() {
break; // no more results
}
for item in &data.organic {
println!("{}. {} — {}", item.rank, item.title, item.link);
}
println!("credits charged: {}", data.credits_charged);
std::thread::sleep(std::time::Duration::from_millis(1500));
}
Ok(())
}
Reuse one Client for every request — building it per call throws away the connection pool, which is the whole reason to keep it around.
What it costs to run
1 credit per successful request, and credits_charged in each response is what actually got billed. New accounts come with 100 free searches, enough to validate the client. If you scale this up with tokio and many concurrent requests, watch for 1029 RATE_LIMITED — the docs map it to QPS or concurrency limits, so cap in-flight requests rather than retrying harder.
FAQ
Blocking or async? Blocking for scripts and cron jobs; reach for tokio + reqwest::Client when you're fetching hundreds of queries and want concurrency with a semaphore.
Why #[serde(default)] instead of Option<String>? Either works. I use defaults with empty strings for display fields and reserve Option<T> for values where "missing" and "empty" mean different things to the caller.
How do I get sitelinks? It's an optional nested array of { title, link, url, ... }; add a Vec<Sitelink> field and serde handles the nesting.
cargo run with SERPBASE_API_KEY set is enough to see real results — the structs are the part you'll reuse. All parameters used here are documented at the link above.