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.