---
title: "How to Validate an Email Address in Rust"
description: "Use reqwest, serde, and async Rust to call nobounce.dev, handle timeouts, and map email validation verdicts at signup."
slug: "validate-email-in-rust"
date: 2026-10-15
updated: 2026-10-15
last_tested: 2026-10-15
summary: "Call /v1/check from an async Rust backend, parse JSON responses safely, handle timeouts without blocking signups, and turn typo suggestions into recoverable user prompts."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "reqwest — HTTP client for Rust"
    url: "https://docs.rs/reqwest/"
  - title: "serde — Serialization and deserialization framework for Rust"
    url: "https://serde.rs/"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "nobounce.dev OpenAPI specification"
    url: "https://nobounce.dev/openapi.json"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

Async Rust backends built with Tokio, Reqwest, and Serde have all the tooling needed to call an email validation API. The core engineering challenge is not making the HTTP request itself. It is keeping the API key secure on the server, bounding the request with a strict timeout, and mapping four distinct verdicts so that a temporary validator or DNS outage never breaks your signup flow.

This guide shows how to implement a robust email validation check in Rust for your registration handlers.

## Inspect the response shape with no credentials

`POST /demo/check` requires no API key or account. It is fixture-only, resolving the frozen corpus listed by `GET https://nobounce.dev/v1/config` to return realistic response shapes for every verdict and reason. It cannot validate arbitrary live addresses.

```bash
curl -X POST https://nobounce.dev/demo/check \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@gmai.com"}'
```

```json
{
  "verdict": "undeliverable",
  "reason": "typosquat_mx",
  "suggestion": "user@gmail.com",
  "confidence": 0.94,
  "checked": { "syntax": true, "mx": true, "typo": true, "disposable": true },
  "cached": false
}
```

Use the demo endpoint to exercise your application branches before switching to `/v1/check` for live DNS validation.

## Keep the API key in server-side configuration

There is no free tier. Live checks require a paid key: hobby is $1/mo for 1,000 checks, pro is $19/mo for 100,000, and scale is $99/mo for 1,500,000. Keys are obtained by redeeming an operator-minted access code or paying self-serve via the Stripe card rail or usevig stablecoin rail.

Load your key from an environment variable:

```bash
export NOBOUNCE_KEY="your-key-from-the-one-time-response"
```

Never expose the key in client-side code, mobile binaries, or frontend bundles. Your frontend should submit registration data to your backend, and your backend calls nobounce. The key belongs in the `Authorization` header as a Bearer token, never in a URL or query parameter.

## Model the frozen contract in Rust structs

Using Serde structs ensures that your JSON parsing is strict and type-safe. It also makes the fail-open contract explicit (`unknown` with `checked.mx == false`):

```rust
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct CheckRequest {
    email: String,
}

#[derive(Deserialize, Debug, Clone)]
pub struct CheckedStatus {
    pub syntax: bool,
    pub mx: bool,
    pub typo: bool,
    pub disposable: bool,
}

#[derive(Deserialize, Debug, Clone)]
pub struct VerdictResponse {
    pub verdict: String,
    pub reason: String,
    pub suggestion: Option<String>,
    pub confidence: f64,
    pub checked: CheckedStatus,
    pub cached: bool,
}

#[derive(Deserialize, Debug)]
pub struct ProblemDetails {
    pub error: String,
    pub fix: String,
}
```

Because `suggestion` can be absent in successful responses, it is correctly modeled as an `Option<String>`.

## Write one bounded check function with Reqwest

```rust
use reqwest::Client;
use std::time::Duration;

const CHECK_URL: &str = "https://nobounce.dev/v1/check";

pub async fn check_email(
    client: &Client,
    api_key: &str,
    email: &str,
) -> Result<Option<VerdictResponse>, String> {
    let payload = CheckRequest {
        email: email.to_string(),
    };

    let response = match client
        .post(CHECK_URL)
        .header("Authorization", format!("Bearer {}", api_key))
        .header("Content-Type", "application/json")
        .json(&payload)
        .timeout(Duration::from_secs(2))
        .send()
        .await
    {
        Ok(res) => res,
        Err(err) => {
            eprintln!("email validation request failed: {}", err);
            return Ok(None); // Fail open on timeout or network error
        }
    };

    if !response.status().is_success() {
        let status = response.status();
        let problem = response
            .json::<ProblemDetails>()
            .await
            .unwrap_or(ProblemDetails {
                error: "unknown_error".into(),
                fix: "Inspect response status and retry.".into(),
            });
        eprintln!("nobounce error [{}]: {} — fix: {}", status, problem.error, problem.fix);
        return Ok(None); // Fail open on API errors
    }

    match response.json::<VerdictResponse>().await {
        Ok(verdict) => Ok(Some(verdict)),
        Err(err) => {
            eprintln!("failed to parse validation response: {}", err);
            Ok(None);
        }
    }
}
```

Reuse a single `reqwest::Client` across requests. The `.timeout(Duration::from_secs(2))` modifier bounds the request so that a hanging DNS lookup or slow network will not stall your server worker. 

Errors returned by the API follow RFC 9457 problem details, providing a stable `error` and a plain-language `fix`. Log those structured fields rather than any sensitive data. Nobounce never stores plaintext email addresses; anything persisted is SHA-256 hashed, while domains are kept in clear.

Returning `Ok(None)` on failure embodies the core principle that an email validation outage must never block a user registration. For further discussion on resilience, see [Fail Open: Email Validation Must Never Block a Signup](https://nobounce.dev/blog/fail-open-email-validation-signup/).

## Map the verdict to application actions

```rust
#[derive(Debug, PartialEq)]
pub enum SignupAction {
    Accept { verdict: String, mx_checked: bool },
    Suggest { suggestion: String, reason: String },
    Reject { reason: String },
}

pub async fn decide_email_action(
    client: &Client,
    api_key: &str,
    email: &str,
) -> SignupAction {
    let result = match check_email(client, api_key, email).await {
        Ok(Some(v)) => v,
        _ => {
            return SignupAction::Accept {
                verdict: "unchecked".into(),
                mx_checked: false,
            }
        }
    };

    if result.verdict == "undeliverable" {
        if let Some(suggestion) = result.suggestion {
            if !suggestion.is_empty() {
                return SignupAction::Suggest {
                    suggestion,
                    reason: result.reason,
                };
            }
        }
        return SignupAction::Reject {
            reason: result.reason,
        };
    }

    SignupAction::Accept {
        verdict: result.verdict,
        mx_checked: result.checked.mx,
    }
}
```

This mapping yields straightforward application control flow:

| API result | Application action |
|---|---|
| `deliverable` | Accept |
| `risky` | Accept (apply secondary policy for disposable/role addresses if needed) |
| `unknown` | Accept and record `checked.mx` |
| `undeliverable` without suggestion | Reject with a reason-specific error message |
| `undeliverable` with suggestion | Prompt the user to confirm the correction |

`risky` covers disposable domains and role accounts like `admin@`. Such addresses can receive mail, so rejecting them is a business rule rather than a validity fact. `unknown` with `checked.mx: false` is the contract's fail-open indicator, ensuring you never guess "deliverable" when DNS fails.

The suggestion branch is where retention happens. Present "Did you mean `user@gmail.com`?" with a one-click correction button alongside a keep-what-I-typed option. Never silently overwrite user input. For a deeper look at correction workflows, read [Suggest the Correction Instead of Rejecting the Signup](https://nobounce.dev/blog/suggest-the-correction-not-rejection/).

## Call on submit and batch-validate lists when needed

Perform validation in your backend submission route or registration handler rather than firing requests on every keystroke. Live DNS calls on partial strings waste quota and cause UI race conditions. Store `verdict`, `reason`, and `checked.mx` to audit check quality. 

If you need to process large lists or audit existing databases later, use the batch endpoint (`/v1/check/batch`), which accepts up to 1,000 addresses per request and keys results by `email_sha256`. Learn how to structure bulk workflows in [How to Batch-Validate a List of Email Addresses](https://nobounce.dev/blog/batch-validate-email-list/).

Finally, verify your timeout and failure paths by pointing the client at a dummy endpoint or tightening timeouts until they trip, confirming that your registration service continues unaffected with `mx_checked: false`.

For complete integration requirements, give your coding agent [/integrate.md](https://nobounce.dev/integrate.md).
