Email verification in C#
C# has one famous trap here and it is worth naming first: new HttpClient() per call exhausts sockets, and a static singleton never notices DNS changes. IHttpClientFactory solves both, which is why every sample below resolves the client from DI.
The other thing that catches people is naming. Our JSON is snake_case and your properties are PascalCase, so the deserializer needs a naming policy or every field comes back null while the code compiles cleanly.
IHttpClientFactory with a named client, System.Text.Json with a snake_case policy, and Polly for the retry policy on the handler.
Install
dotnet add package Microsoft.Extensions.Http.PollyVerify 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.
public record VerifyResult(
string Email,
string Result,
string Reason,
string? SmtpCode,
bool Billed,
DateTimeOffset CheckedAt);
public sealed class ZapBounceClient(HttpClient http)
{
// Our JSON is snake_case. Without this policy every property silently binds null.
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
public async Task<VerifyResult> VerifyAsync(string email, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync("v1/verify", new { email }, ct);
if (!response.IsSuccessStatusCode)
throw await ZapBounceException.FromResponseAsync(response, ct);
return (await response.Content.ReadFromJsonAsync<VerifyResult>(Json, ct))!;
}
}
// Program.cs — one registration, and socket exhaustion stops being your problem.
builder.Services.AddHttpClient<ZapBounceClient>(c =>
{
c.BaseAddress = new Uri("https://api.zapbounce.com/");
c.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", builder.Configuration["ZapBounce:Key"]);
c.Timeout = TimeSpan.FromSeconds(15);
});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 async Task<string> SubmitBatchAsync(
string name, IReadOnlyCollection<string> emails, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/batches")
{
Content = JsonContent.Create(new { name, emails }),
};
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
using var response = await http.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<BatchCreated>(Json, ct);
return body!.BatchId;
}
/// IAsyncEnumerable over the cursor: the caller can await foreach without buffering.
public async IAsyncEnumerable<ResultRow> ResultsAsync(
string batchId, [EnumeratorCancellation] CancellationToken ct = default)
{
string? cursor = null;
do
{
var url = quot;v1/batches/{batchId}/results?limit=1000"
+ (cursor is null ? "" : quot;&cursor={Uri.EscapeDataString(cursor)}");
var page = await http.GetFromJsonAsync<Page<ResultRow>>(url, Json, ct);
foreach (var row in page!.Data)
yield return row;
cursor = page.HasMore ? page.NextCursor : null;
}
while (cursor is not null);
}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.
public sealed class ZapBounceException(string code, string message, string requestId, HttpStatusCode status)
: Exception(message)
{
public string Code { get; } = code;
public string RequestId { get; } = requestId;
public HttpStatusCode Status { get; } = status;
public bool Retryable =>
Status == HttpStatusCode.TooManyRequests || (int)Status >= 500;
public static async Task<ZapBounceException> FromResponseAsync(
HttpResponseMessage response, CancellationToken ct)
{
var body = await response.Content.ReadFromJsonAsync<ApiErrorEnvelope>(cancellationToken: ct);
var e = body?.Error;
return new ZapBounceException(
e?.Code ?? "unknown", e?.Message ?? response.ReasonPhrase ?? "request failed",
e?.RequestId ?? "", response.StatusCode);
}
}
// Polly on the handler: retries happen below your code, so callers stay clean.
builder.Services.AddHttpClient<ZapBounceClient>(/* ... */)
.AddPolicyHandler(HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(5, attempt =>
TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 250
+ Random.Shared.Next(0, 250))));C#: common questions
Why IHttpClientFactory rather than a static client?
A static client pins DNS for the process lifetime, so a failover on our side is invisible to your app until it restarts. The factory rotates handlers on a timer and keeps the socket pooling.
Does this work on .NET Framework?
The JSON naming policy needs .NET 8 or later. On Framework, use Newtonsoft with a SnakeCaseNamingStrategy and the rest carries over.
Where should verification run in ASP.NET Core?
In a hosted service or a queued job. Verifying inside a request means a slow mail server becomes a slow page.
Run this C# code today
100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.