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.

curl -X POST https://nobounce.dev/demo/check \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@gmai.com"}'
{
  "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:

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):

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

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.

Map the verdict to application actions

#[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.

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.

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.