Key Takeaways

  • The idiomatic ASP.NET Core pattern is a typed HttpClient registered through dependency injection, never a raw new HttpClient() per call.
  • System.Text.Json with a strongly typed result record deserializes the v2 response cleanly without third-party JSON libraries.
  • Polly, integrated through AddPolicyHandler, adds retry with exponential backoff and a circuit breaker at the HTTP layer with no custom retry code.
  • A 16-stage verification on the v2 endpoint surfaces mailbox existence plus disposable, role-account, and gibberish flags, gating signups before bad data reaches the database.

C# and ASP.NET Core have the cleanest dependency injection story of any major web framework, and email verification is a textbook case for it. Rather than newing up an HttpClient in a controller, you register a typed client in the service container, attach a Polly retry policy at registration, and inject the verifier wherever it is needed. This guide builds that pattern end to end against the v2 API, on .NET 8 and C# 12.

The complete reference implementation, including framework-specific notes for Minimal APIs and MVC, is documented on the verify email with C# integration page.

The v2 Endpoint

Confirm the endpoint against a known address before writing C#. 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 Result Record and Typed Client

Model the response as a record with JsonPropertyName attributes so System.Text.Json maps the snake_case API fields to C# properties. The client itself is a class that takes an injected HttpClient, which is what makes it a typed client in ASP.NET Core terms.

// VerificationResult.cs
using System.Text.Json.Serialization;

public record VerificationResult
{
    [JsonPropertyName("email")]
    public string Email { get; init; } = "";

    [JsonPropertyName("status")]
    public string Status { get; init; } = "";

    [JsonPropertyName("sub_status")]
    public string SubStatus { get; init; } = "";

    [JsonPropertyName("isDisposable")]
    public bool IsDisposable { get; init; }

    [JsonPropertyName("isRoleAccount")]
    public bool IsRoleAccount { get; init; }

    [JsonPropertyName("isGibberish")]
    public bool IsGibberish { get; init; }
}

// IEmailVerifier.cs
public interface IEmailVerifier
{
    Task<VerificationResult?> VerifyAsync(
        string email, CancellationToken ct = default);
}

// EmailVerifier.cs
public class EmailVerifier : IEmailVerifier
{
    private readonly HttpClient _http;
    private readonly string _apiKey;

    public EmailVerifier(HttpClient http, IConfiguration config)
    {
        _http = http;
        _apiKey = config["EmailVerifier:ApiKey"]
            ?? throw new InvalidOperationException("API key missing");
    }

    public async Task<VerificationResult?> VerifyAsync(
        string email, CancellationToken ct = default)
    {
        var url = $"v2/verify?api_key={_apiKey}" +
                  $"&email={Uri.EscapeDataString(email)}";

        using var response = await _http.GetAsync(url, ct);
        response.EnsureSuccessStatusCode();

        return await response.Content
            .ReadFromJsonAsync<VerificationResult>(cancellationToken: ct);
    }
}

The verifier takes HttpClient through its constructor. It does not create the client, configure the base address, or manage its lifetime. That is all handled at registration, which is the pattern that avoids socket exhaustion from new HttpClient() and gives Polly a place to attach.

Common Mistake Never write new HttpClient() inside a request handler. It leaks sockets under load and breaks connection pooling. Register a typed client with AddHttpClient and let the factory manage lifetime, which is the entire reason the typed client pattern exists.

Registration With Polly Retry

Register the typed client in Program.cs. Set the base address and timeout once, and attach a Polly retry policy that handles transient HTTP failures with exponential backoff. This is where the resilience lives.

// Program.cs
using Polly;
using Polly.Extensions.Http;

var builder = WebApplication.CreateBuilder(args);

static IAsyncPolicy<HttpResponseMessage> RetryPolicy() =>
    HttpPolicyExtensions
        .HandleTransientHttpError()
        .WaitAndRetryAsync(3, attempt =>
            TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt)));

builder.Services.AddHttpClient<IEmailVerifier, EmailVerifier>(client =>
{
    client.BaseAddress = new Uri("https://emailverifierapi.com/");
    client.Timeout = TimeSpan.FromSeconds(10);
})
.AddPolicyHandler(RetryPolicy());

builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

The retry policy handles 5xx responses and network failures with three attempts at exponentially increasing delays. Because it is attached at the HttpClient layer, every call through the typed client gets the resilience automatically, and the verifier class stays clean of retry logic.

A 16-stage technical audit runs on every verification request. Source: EmailVerifierAPI v2 verification pipeline

The Controller

The controller injects IEmailVerifier and gates the signup. A failed status or a disposable flag returns 422 before the user record is ever created.

// SignupController.cs
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class SignupController : ControllerBase
{
    private readonly IEmailVerifier _verifier;

    public SignupController(IEmailVerifier verifier)
        => _verifier = verifier;

    [HttpPost]
    public async Task<IActionResult> Post(
        [FromBody] SignupRequest req, CancellationToken ct)
    {
        var result = await _verifier.VerifyAsync(req.Email, ct);

        if (result is null)
            return StatusCode(503, new { error = "verification_unavailable" });

        if (result.Status == "failed")
            return UnprocessableEntity(new {
                error = "email_invalid",
                subStatus = result.SubStatus
            });

        if (result.IsDisposable)
            return UnprocessableEntity(new { error = "disposable_email" });

        // Persist the user with verification metadata
        // await _users.CreateAsync(req.Email, result);

        return Ok(new { email = result.Email, status = result.Status });
    }
}

public record SignupRequest(string Email, string Name);

The CancellationToken flows from the controller through the verifier to the HttpClient, so a cancelled request aborts the in-flight verification cleanly. This is the idiomatic async pattern in ASP.NET Core and matters under load.

Notice how the controller treats the three failure categories distinctly. A null result means the verification service itself was unreachable after retries, which is an infrastructure problem and returns 503 so the client can retry later. A failed status means the address is genuinely bad and returns 422 with the specific sub_status so the front end can show a useful message. A disposable flag is a policy rejection rather than a deliverability problem, so it returns its own error code. Collapsing these into a single generic rejection loses the signal that downstream systems and front-end UX both need.

For a typed client, the verification metadata should be persisted alongside the user record rather than discarded after the gate decision. Storing the status, sub_status, and risk flags lets later processes (lead scoring, fraud review, deliverability segmentation) read the verification result without re-calling the API. The verification you already paid for becomes a reusable data point rather than a one-time gate check.

For broader integration patterns across other languages, the email verification integrations hub covers Node.js, Python, Go, Java, PHP, Ruby, 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

Why use a typed HttpClient instead of IHttpClientFactory directly?

The typed client gives you a strongly typed service to inject, encapsulates the API logic in one class, and still gets all the factory benefits: managed lifetime, connection pooling, and a place to attach Polly policies. It is the recommended pattern in the ASP.NET Core documentation.

Where should the API key be stored?

In configuration, loaded from User Secrets in development and environment variables or a key vault in production. Read it through IConfiguration as shown. Never hardcode the key or commit it to source control.

Does this work with Minimal APIs?

Yes. Register the typed client the same way in Program.cs, then inject IEmailVerifier directly into the endpoint delegate. The verifier and result record are identical; only the endpoint definition changes from a controller to a MapPost call.

How do I handle the verification service being down?

The controller returns 503 when the result is null after retries are exhausted. For a fail-soft approach, allow the signup with an unverified flag and re-verify asynchronously through a background hosted service once the API recovers.