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 statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.
// Program.cs — registration, once.
builder.Services.AddHttpClient<IVerificationClient, VerificationClient>(client =>
{
client.BaseAddress = new Uri("https://app.zapbounce.com/api/v1/");
client.DefaultRequestHeaders.Add("X-API-Key", 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
{
// POST /verify with { "emails": [email] }; returns results[0] (status, reasonCode, flags, smtpCode, checkedAt).
Task<VerifyResult> VerifyAsync(string email, CancellationToken ct = default);
// POST /jobs with { "emails": [...], "source": "api" }; returns the job id to poll.
Task<string> SubmitJobAsync(IReadOnlyCollection<string> emails, CancellationToken ct = default);
}Verify a list
Submit a job 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 jobs: one call of 5,000 beats 5,000 calls (a job takes up to 50,000).
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 jobId = await client.SubmitJobAsync(emails, ct);
logger.LogInformation("submitted {Count} addresses as job {JobId}", emails.Count, jobId);
// No webhook: a separate poller reads GET jobs/{id} until the status is
// completed, partial, failed or cancelled, then pages GET jobs/{id}/emails.
}
catch (ZapBounceException e) when (e.Code == "CREDITS_INSUFFICIENT")
{
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 422 CREDITS_INSUFFICIENT 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} requestId={RequestId}", e.Code, e.RequestId);
return VerifyResult.Unresolved(email);
}
}
public Task<string> SubmitJobAsync(IReadOnlyCollection<string> emails, CancellationToken ct = default)
=> inner.SubmitJobAsync(emails, ct); // jobs 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 refunded.