Email verification in .NET
.NET's hosting model gives verification a natural home away from the request path. A BackgroundService reading a Channel keeps list work off your controllers, and the typed-client registration keeps the HTTP configuration in one place.
If you have arrived from the C# page, the API calls are identical. What follows is the hosting shape: where the work runs, how it is bounded, and how it shuts down without dropping a batch.
Typed HttpClient registered through DI, a bounded Channel<T> as the work queue, and a BackgroundService draining it.
Install
dotnet add package Microsoft.Extensions.HttpVerify one address
You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.
// Program.cs — registration, once.
builder.Services.AddHttpClient<IVerificationClient, VerificationClient>(client =>
{
client.BaseAddress = new Uri("https://api.zapbounce.com/v1/");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", builder.Configuration["ZapBounce:Key"]);
client.Timeout = TimeSpan.FromSeconds(15);
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5)); // picks up DNS changes on failover
builder.Services.AddSingleton(Channel.CreateBounded<string>(
new BoundedChannelOptions(10_000) { FullMode = BoundedChannelFullMode.Wait }));
builder.Services.AddHostedService<VerificationWorker>();
// The interface exists so a test can substitute it without an HTTP stub.
public interface IVerificationClient
{
Task<VerifyResult> VerifyAsync(string email, CancellationToken ct = default);
Task<string> SubmitBatchAsync(string name, IReadOnlyCollection<string> emails, CancellationToken ct = default);
}Verify a list
Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
public sealed class VerificationWorker(
Channel<string> channel,
IVerificationClient client,
IServiceScopeFactory scopes,
ILogger<VerificationWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Buffer addresses and submit as batches: one call of 5,000 beats 5,000 calls.
var buffer = new List<string>(capacity: 5_000);
await foreach (var email in channel.Reader.ReadAllAsync(stoppingToken))
{
buffer.Add(email);
if (buffer.Count < 5_000) continue;
await FlushAsync(buffer, stoppingToken);
buffer.Clear();
}
// Shutdown: whatever is buffered still gets submitted.
if (buffer.Count > 0)
await FlushAsync(buffer, CancellationToken.None);
}
private async Task FlushAsync(List<string> emails, CancellationToken ct)
{
try
{
var batchId = await client.SubmitBatchAsync(quot;worker-{DateTime.UtcNow:O}", emails, ct);
logger.LogInformation("submitted {Count} addresses as {BatchId}", emails.Count, batchId);
}
catch (ZapBounceException e) when (e.Code == "insufficient_credits")
{
logger.LogError("out of credits; {Count} addresses returned to the queue", emails.Count);
foreach (var email in emails) await channel.Writer.WriteAsync(email, ct);
}
}
}Errors and retries
A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.
// Fail open at the edge, and record why. An unverifiable address is not a bad one.
public sealed class SafeVerificationClient(
IVerificationClient inner,
ILogger<SafeVerificationClient> logger) : IVerificationClient
{
public async Task<VerifyResult> VerifyAsync(string email, CancellationToken ct = default)
{
try
{
return await inner.VerifyAsync(email, ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
// HttpClient surfaces its own timeout as TaskCanceledException. The
// guard is what separates "we timed out" from "the caller canceled".
logger.LogWarning("verification timed out for {Email}", email);
return VerifyResult.Unresolved(email);
}
catch (ZapBounceException e) when (e.Retryable)
{
logger.LogWarning("transient failure {Code} request_id={RequestId}", e.Code, e.RequestId);
return VerifyResult.Unresolved(email);
}
}
public Task<string> SubmitBatchAsync(string name, IReadOnlyCollection<string> emails, CancellationToken ct = default)
=> inner.SubmitBatchAsync(name, emails, ct); // batches are queued; let them throw
}
// Registration order matters: the decorator wraps the typed client.
builder.Services.Decorate<IVerificationClient, SafeVerificationClient>();.NET: common questions
How is this different from the C# page?
Same API calls. This page is about where the work runs: the hosted service, the bounded channel, and shutdown that does not drop a buffered batch.
Why a bounded channel?
An unbounded one turns a backlog into an out-of-memory crash. Bounded with FullMode.Wait applies backpressure to the producer, which is what you want.
Does the handler lifetime matter?
Yes. The default five minutes is what lets a DNS change reach a long-running process. A static HttpClient never picks it up.
Run this .NET code today
100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.