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 camelCase and your properties are PascalCase, so a bare JsonSerializerOptions binds nothing and every field comes back null while the code compiles cleanly.

IHttpClientFactory with a named client, System.Text.Json with web defaults (camelCase), and Polly for the retry policy on the handler.

Install

Shell
dotnet add package Microsoft.Extensions.Http.Polly

Verify 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.

C#
public record VerifyResult(
    string Id,
    string Email,
    string Status,            // valid | invalid | catch_all | unknown
    string ReasonCode,
    string? Provider,
    string[] Flags,           // role, disposable, free_provider
    int? SmtpCode,
    DateTimeOffset CheckedAt);

public record VerifyResponse(string Node, VerifyResult[] Results);

public sealed class ZapBounceClient(HttpClient http)
{
    // Our JSON is camelCase. A bare JsonSerializerOptions is case-sensitive and silently binds null.
    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);

    public async Task<VerifyResult> VerifyAsync(string email, CancellationToken ct = default)
    {
        using var response = await http.PostAsJsonAsync("verify", new { emails = new[] { email } }, ct);

        if (!response.IsSuccessStatusCode)
            throw await ZapBounceException.FromResponseAsync(response, ct);

        var body = await response.Content.ReadFromJsonAsync<VerifyResponse>(Json, ct);
        return body!.Results[0];
    }
}

// Program.cs — one registration, and socket exhaustion stops being your problem.
builder.Services.AddHttpClient<ZapBounceClient>(c =>
{
    c.BaseAddress = new Uri("https://app.zapbounce.com/api/v1/");
    c.DefaultRequestHeaders.Add("X-API-Key", builder.Configuration["ZapBounce:Key"]);
    c.Timeout = TimeSpan.FromSeconds(15);
});

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.

C#
public record JobCreated(string Id, string Status);
public record Job(string Id, string Status, int ValidEmails, int InvalidEmails, int CatchAllEmails, int OtherEmails);
public record PageMeta(int Page, int PageSize, int Total, int TotalPages);
public record Page<T>(T[] Items, PageMeta Meta);
public record ResultRow(string Email, string Status, string ReasonCode, string[] Flags);

public async Task<string> SubmitJobAsync(
    IReadOnlyCollection<string> emails, CancellationToken ct = default)
{
    using var request = new HttpRequestMessage(HttpMethod.Post, "jobs")
    {
        Content = JsonContent.Create(new { emails, source = "api" }),   // up to 50,000 per job
    };
    request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());

    using var response = await http.SendAsync(request, ct);
    response.EnsureSuccessStatusCode();

    var body = await response.Content.ReadFromJsonAsync<JobCreated>(Json, ct);
    return body!.Id;
}

/// No webhook: poll until the job reaches a terminal status.
public async Task<Job> WaitForJobAsync(string id, CancellationToken ct = default)
{
    string[] done = ["completed", "partial", "failed", "cancelled"];
    while (true)
    {
        var job = (await http.GetFromJsonAsync<Job>(
quot;jobs/{id}", Json, ct))!; if (done.Contains(job.Status)) return job; await Task.Delay(TimeSpan.FromSeconds(5), ct); } } /// IAsyncEnumerable over the pages: the caller can await foreach without buffering. public async IAsyncEnumerable<ResultRow> RowsAsync( string id, [EnumeratorCancellation] CancellationToken ct = default) { var page = 1; Page<ResultRow>? body; do { body = await http.GetFromJsonAsync<Page<ResultRow>>(
quot;jobs/{id}/emails?page={page}&pageSize=100", Json, ct); foreach (var row in body!.Items) yield return row; } while (page++ < body.Meta.TotalPages); }

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.

C#
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;

    // CREDITS_INSUFFICIENT is a 422 and never succeeds on retry.
    public bool Retryable =>
        Status == HttpStatusCode.TooManyRequests || (int)Status >= 500;

    public static async Task<ZapBounceException> FromResponseAsync(
        HttpResponseMessage response, CancellationToken ct)
    {
        // Error body: { statusCode, code, message, requestId }
        var e = await response.Content.ReadFromJsonAsync<ApiError>(cancellationToken: ct);
        return new ZapBounceException(
            e?.Code ?? "unknown", e?.Message ?? response.ReasonPhrase ?? "request failed",
            e?.RequestId ?? "", response.StatusCode);
    }
}

public record ApiError(int StatusCode, string Code, string Message, string RequestId);

// Polly on the handler: retries happen below your code, so callers stay clean.
// There is no Retry-After header, so a 429 waits on X-RateLimit-Window-Seconds instead.
builder.Services.AddHttpClient<ZapBounceClient>(/* ... */)
    .AddPolicyHandler(HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(5,
            (attempt, outcome, _) =>
            {
                var window = outcome.Result is { } r
                    && r.StatusCode == HttpStatusCode.TooManyRequests
                    && r.Headers.TryGetValues("X-RateLimit-Window-Seconds", out var v)
                    && int.TryParse(v.First(), out var s) ? s : 1;
                return TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 250 * window
                    + Random.Shared.Next(0, 250));
            },
            (_, _, _, _) => Task.CompletedTask));

// At signup, fail open when the call times out:
// catch (TaskCanceledException) { return new VerifyResult("", email, "unknown", "TIMEOUT", null, [], null, DateTimeOffset.UtcNow); }

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 primary-constructor syntax needs .NET 8 or later. On Framework, use Newtonsoft, which matches camelCase case-insensitively, 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 refunded.