emailverifier.dev
Posts

ASP.NET Core Email Validation Tutorial for Registration APIs

| 5 min read | Usama Ejaz
ASP.NET Core Email Validation Tutorial for Registration APIs

This ASP.NET Core endpoint has one rule: no user mutation runs until the server receives an email-verification decision. The API key lives in .NET configuration, IHttpClientFactory owns the outbound client, and the submitted password never leaves the application.

The example targets .NET 10 and uses the current minimal API template.

Create the API and store the key

Create a minimal web API:

dotnet new webapi \
  --framework net10.0 \
  --no-openapi \
  --output AspNetEmailCheck
cd AspNetEmailCheck

The .NET 10 SDK creates a minimal API by default when dotnet new webapi is used without the controllers option. Microsoft lists that behavior in the current SDK template reference.

Create an emailverifier.dev project and copy its API key. Store it with ASP.NET Core Secret Manager for local development:

dotnet user-secrets init
dotnet user-secrets set "EmailVerifier:ApiKey" "your-project-key"

Secret Manager keeps development values outside the project tree. It is not a production secret store. Microsoft documents the setup and production boundary in Safe storage of app secrets in development.

Replace Program.cs with the complete route

using System.ComponentModel.DataAnnotations;
using System.Net.Http.Json;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);

var apiKey = builder.Configuration["EmailVerifier:ApiKey"]
    ?? throw new InvalidOperationException(
        "EmailVerifier:ApiKey is not configured.");

builder.Services.AddHttpClient("emailverifier", client =>
{
    client.BaseAddress = new Uri("https://emailverifier.dev/");
    client.DefaultRequestHeaders.Add("X-API-Key", apiKey);
    client.Timeout = TimeSpan.FromSeconds(4);
});

var app = builder.Build();

app.MapPost("/signup", async (
    SignupRequest input,
    IHttpClientFactory clients,
    CancellationToken cancellationToken) =>
{
    var validationResults = new List<ValidationResult>();
    var validationContext = new ValidationContext(input);

    if (!Validator.TryValidateObject(
        input,
        validationContext,
        validationResults,
        validateAllProperties: true))
    {
        var errors = validationResults
            .SelectMany(result =>
                result.MemberNames.DefaultIfEmpty("request")
                    .Select(member => new
                    {
                        Member = member,
                        Message = result.ErrorMessage ?? "Invalid value."
                    }))
            .GroupBy(error => error.Member)
            .ToDictionary(
                group => group.Key,
                group => group.Select(error => error.Message).ToArray());

        return Results.ValidationProblem(errors);
    }

    VerificationResult? result;
    try
    {
        var client = clients.CreateClient("emailverifier");
        using var response = await client.PostAsJsonAsync(
            "api/v1/verify",
            new { input.Email },
            cancellationToken);

        if (!response.IsSuccessStatusCode)
        {
            return Results.Problem(
                statusCode: StatusCodes.Status503ServiceUnavailable,
                title: "Email verification is temporarily unavailable.");
        }

        result = await response.Content
            .ReadFromJsonAsync<VerificationResult>(
                cancellationToken: cancellationToken);
    }
    catch (OperationCanceledException)
        when (!cancellationToken.IsCancellationRequested)
    {
        return Results.Problem(
            statusCode: StatusCodes.Status503ServiceUnavailable,
            title: "Email verification timed out.");
    }
    catch (HttpRequestException)
    {
        return Results.Problem(
            statusCode: StatusCodes.Status503ServiceUnavailable,
            title: "Email verification is temporarily unavailable.");
    }

    if (result is null ||
        !VerificationResult.KnownStatuses.Contains(result.Status) ||
        !VerificationResult.KnownActions.Contains(result.Action))
    {
        return Results.Problem(
            statusCode: StatusCodes.Status503ServiceUnavailable,
            title: "Email verification returned an unexpected response.");
    }

    if (result.Action == "block")
    {
        var message = result.Signals.Contains("disposable_address")
            ? "Use a permanent email address."
            : "Check the email address and try again.";

        return Results.UnprocessableEntity(new
        {
            message,
            status = result.Status,
            signals = result.Signals,
            suggestion = result.Suggestion
        });
    }

    if (result.Action == "review")
    {
        return Results.Json(
            new
            {
                decision = "review",
                email = result.Email,
                status = result.Status,
                signals = result.Signals,
                suggestion = result.Suggestion
            },
            statusCode: StatusCodes.Status202Accepted);
    }

    // Insert the user with your existing account service here.
    // Do not send input.Password to the verification API.
    return Results.Ok(new
    {
        decision = "allow",
        email = result.Email,
        status = result.Status
    });
});

app.Run();

public sealed record SignupRequest(
    [property: Required, EmailAddress] string Email,
    [property: Required, MinLength(8), MaxLength(128)] string Password);

public sealed record VerificationResult(
    [property: JsonPropertyName("email")] string Email,
    [property: JsonPropertyName("status")] string Status,
    [property: JsonPropertyName("action")] string Action,
    [property: JsonPropertyName("flagged")] bool Flagged,
    [property: JsonPropertyName("signals")] string[] Signals,
    [property: JsonPropertyName("suggestion")] string? Suggestion)
{
    public static readonly HashSet<string> KnownStatuses =
        ["deliverable", "risky", "undeliverable", "unknown"];

    public static readonly HashSet<string> KnownActions =
        ["allow", "review", "block"];
}

AddHttpClient gives the application a named client with one base URL, API-key header, and timeout. Microsoft recommends IHttpClientFactory for central configuration and handler lifetime management in its current ASP.NET Core HTTP request guide.

The JSON body is bound to the SignupRequest record. The endpoint validates it before creating the client, so a malformed email or short password consumes no verification credit. Minimal API binding from JSON and dependency injection are covered in Microsoft's parameter-binding reference.

Run the endpoint

dotnet run

Use the HTTP URL shown in the console, then submit a signup. Replace the port below when your launch profile chooses a different one:

curl http://localhost:5000/signup \
  -H "Content-Type: application/json" \
  -d '{
    "email": "person@example.com",
    "password": "correct-horse"
  }'

Read the HTTP result without losing the email status

Verification result Endpoint response Account behavior
deliverable and allow 200 Run the existing user insert at the marked line.
deliverable, risky, or unknown with review 202 Continue through confirmation or restricted access.
risky or undeliverable with block 422 Create no user. Disposable addresses receive a specific message.
Timeout, non-success response, or invalid JSON contract 503 Apply the product's fail-open or fail-closed availability policy.

unknown is a real verification status, not an exception. It means recipient evidence was inconclusive. The REST API reference defines all returned fields, and the production result guide extends this route with logging, retries, and stored decisions.

The sample fails closed only when the address result says block. It returns 503 for service availability failures so your caller can decide whether a low-risk signup should retry or continue with confirmation. The route is ready to connect when its allow branch is the only path that can reach your account service.

Continue reading