Key Takeaways

  • Rust verification is built on three crates: reqwest for HTTP, serde for typed JSON deserialization, and tokio for async concurrency.
  • A single shared reqwest::Client wrapped in Arc gives connection pooling and keep-alive across all concurrent tasks.
  • A tokio Semaphore bounds concurrency so a bulk job stays within the API rate limit without spawning unbounded tasks.
  • serde maps the snake_case v2 response onto a typed struct, and Rust's ? operator propagates errors cleanly through the async call chain.

Rust is an excellent fit for email verification work because the type system catches integration mistakes at compile time and the async story scales cleanly from a single signup check to a bulk job over millions of addresses. The ecosystem makes it straightforward: reqwest for the HTTP client, serde for JSON, and tokio for the async runtime. This guide builds a production verification client against the v2 API, then extends it into a concurrent worker pool bounded by a semaphore.

The complete reference implementation, including blocking-client variants and framework notes, is documented on the verify email with Rust integration page.

The v2 Endpoint

Confirm the endpoint against a known address before writing Rust. The v2 verify endpoint takes the email as a query parameter and returns a JSON document with all the decision fields.

curl -X GET 
  "https://emailverifierapi.com/v2/verify?api_key=YOUR_API_KEY&email=jane@example.com"

# Response
{
  "email": "jane@example.com",
  "status": "passed",
  "sub_status": "mailboxExists",
  "isDisposable": false,
  "isFreeService": false,
  "isOffensive": false,
  "isRoleAccount": false,
  "isGibberish": false,
  "smtp_check": "success"
}

The status field drives the decision. Passed means the mailbox exists and accepts mail. Failed means the mailbox does not exist or the domain has no MX server. Unknown covers greylisting and transient errors. The boolean flags surface risk categories worth handling separately.

The Typed Client

Define a struct that mirrors the response and derive serde's Deserialize. The client wraps a reqwest::Client and exposes an async verify method. The serde rename attributes map snake_case API fields onto idiomatic Rust field names.

// Cargo.toml
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// serde = { version = "1", features = ["derive"] }
// tokio = { version = "1", features = ["full"] }

use serde::Deserialize;
use std::sync::Arc;

#[derive(Debug, Deserialize, Clone)]
pub struct Verification {
    pub email: String,
    pub status: String,
    pub sub_status: String,
    #[serde(rename = "isDisposable")]
    pub is_disposable: bool,
    #[serde(rename = "isRoleAccount")]
    pub is_role_account: bool,
    #[serde(rename = "isGibberish")]
    pub is_gibberish: bool,
}

#[derive(Clone)]
pub struct Verifier {
    http: reqwest::Client,
    api_key: Arc<String>,
}

impl Verifier {
    pub fn new(api_key: String) -> Self {
        Self {
            http: reqwest::Client::builder()
                .timeout(std::time::Duration::from_secs(10))
                .build()
                .expect("client builds"),
            api_key: Arc::new(api_key),
        }
    }

    pub async fn verify(&self, email: &str)
        -> Result<Verification, reqwest::Error>
    {
        let url = "https://emailverifierapi.com/v2/verify";
        let resp = self.http
            .get(url)
            .query(&[("api_key", self.api_key.as_str()), ("email", email)])
            .send()
            .await?
            .error_for_status()?
            .json::<Verification>()
            .await?;
        Ok(resp)
    }
}

Three things make this idiomatic. The client builds once with a timeout and is cloned cheaply because reqwest::Client is internally an Arc. The query method handles URL encoding so the email never needs manual escaping. The error_for_status call converts any non-2xx response into an error that the ? operator propagates, and json deserializes straight into the typed struct.

Pro Tip Build one reqwest::Client and clone it for each task. The client is an Arc internally, so cloning is cheap and all clones share the same connection pool. Creating a new client per request throws away keep-alive and exhausts sockets under load.

Real-Time Verification at Signup

For a single signup check, call verify directly and branch on the result. The match expression makes the decision logic explicit and exhaustive, which is exactly what Rust's type system encourages.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key = std::env::var("EMAILVERIFIER_API_KEY")?;
    let verifier = Verifier::new(api_key);

    let result = verifier.verify("jane@example.com").await?;

    match result.status.as_str() {
        "passed" if !result.is_disposable => {
            println!("accept: {}", result.email);
        }
        "passed" => {
            println!("reject: disposable address");
        }
        "failed" => {
            println!("reject: {} ({})", result.email, result.sub_status);
        }
        _ => {
            println!("retry later: transient or unknown");
        }
    }
    Ok(())
}

The match arm with a guard (passed if not disposable) shows how Rust lets you express the accept condition precisely. Disposable addresses that technically pass mailbox verification still get rejected, and the compiler ensures every status variant is handled.

A shared client plus a semaphore verifies 100,000 addresses without exhausting sockets or rate limits. Source: v2 API throughput benchmarks, 2025

Bulk Verification With a Bounded Worker Pool

For bulk work, spawn a tokio task per address but bound concurrency with a Semaphore so the job stays within the API rate limit. Each task acquires a permit before calling the API and releases it when done, capping the number of in-flight requests.

use tokio::sync::Semaphore;
use std::sync::Arc;

pub async fn verify_bulk(
    verifier: Verifier,
    emails: Vec<String>,
    max_concurrent: usize,
) -> Vec<(String, Result<Verification, String>)> {
    let sem = Arc::new(Semaphore::new(max_concurrent));
    let mut handles = Vec::with_capacity(emails.len());

    for email in emails {
        let verifier = verifier.clone();
        let sem = Arc::clone(&sem);

        handles.push(tokio::spawn(async move {
            // Permit is held until it drops at end of scope
            let _permit = sem.acquire().await.unwrap();
            let outcome = verifier
                .verify(&email)
                .await
                .map_err(|e| e.to_string());
            (email, outcome)
        }));
    }

    let mut results = Vec::new();
    for handle in handles {
        if let Ok(pair) = handle.await {
            results.push(pair);
        }
    }
    results
}

The Semaphore is the rate-control mechanism. With 16 permits, at most 16 requests are ever in flight regardless of how many addresses are queued. Tasks that cannot acquire a permit wait, which naturally throttles the job to a sustainable rate. The cloned Verifier shares the same underlying connection pool across every task.

For broader integration patterns across other languages, the email verification integrations hub covers Go, Python, Node.js, Java, PHP, and the rest. New developers can grab 100 free email verification credits on signup to test the integration, and the email verification API documentation covers the full response schema.

Frequently Asked Questions

Should I use the async or blocking reqwest API?

Use async (the default) for servers, bulk jobs, or anything needing concurrency. Use reqwest::blocking only for simple scripts where async overhead is unnecessary. The typed client and serde struct are identical either way; only the runtime and the await points change.

Why wrap the client in Arc?

reqwest::Client is already an Arc internally, so cloning it is cheap and shares the connection pool. Wrapping the api_key in Arc avoids cloning the string for every task. The combination lets you cheaply clone the whole Verifier into each spawned task.

How does the semaphore control rate?

A Semaphore with N permits allows at most N tasks to hold a permit at once. Each task acquires a permit before its API call and releases it after, so no more than N requests are ever in flight. This bounds concurrency to a sustainable rate without a manual rate-limiting loop.

How do I handle the API key securely in Rust?

Read it from an environment variable with std::env::var at startup, never hardcode it. For production, load it through your deployment platform's secret manager. The example uses EMAILVERIFIER_API_KEY, which fails fast at startup if the variable is missing.