.NET has shipped a competent HTTP client and a competent JSON serializer in the box since .NET 5, so calling an email validation API from C# needs no NuGet packages at all: HttpClient and System.Text.Json cover it. The interesting work is the same as in any language: keep the key server-side, bound the call with a deadline, and map four verdicts so a validator outage can never become a signup outage.

This example is a helper for a registration endpoint. It targets .NET 8 and C# 12, but everything except collection expressions works unchanged on .NET 6.

Inspect the response shape with no credentials

POST /demo/check needs no key, but it is fixture-only. It resolves the frozen corpus listed by GET https://nobounce.dev/v1/config and returns real response shapes for every verdict and reason. It cannot validate an arbitrary live address.

curl -X POST https://nobounce.dev/demo/check \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@gmai.com"}'
{
  "verdict": "undeliverable",
  "reason": "typosquat_mx",
  "suggestion": "user@gmail.com",
  "confidence": 0.94,
  "checked": { "syntax": true, "mx": true, "typo": true, "disposable": true },
  "cached": false
}

Use the demo endpoint to write your branches. Switch to /v1/check only when you need live DNS.

Put the key in the process environment

There is no free tier. Live checks require a paid key: hobby is $1/mo for 1,000 checks, pro is $19/mo for 100,000, and scale is $99/mo for 1,500,000. Keys come from redeeming an operator-minted access code, or from paying self-serve on the Stripe card rail or the usevig stablecoin rail.

export NOBOUNCE_KEY="your-key-from-the-one-time-response"

Keep the key out of repositories, client bundles, and mobile apps. Your signup controller calls nobounce; the browser calls your controller. The key belongs in an Authorization header, never in a URL or query parameter.

var key = Environment.GetEnvironmentVariable("NOBOUNCE_KEY");

Model the frozen contract as records

The verdict object is frozen. Encoding it as records keeps deserialization honest and makes the fail-open signal (unknown with checked.mx == false) impossible to miss.

One C#-specific trap: the JSON field is checked, which is a C# keyword, so you cannot name a property that verbatim. Pascal-cased record properties with case-insensitive matching solve it without an attribute:

public sealed record Checked(bool Syntax, bool Mx, bool Typo, bool Disposable);

public sealed record Verdict(
    string Verdict,
    string Reason,
    string? Suggestion,   // null and "" are different facts
    double Confidence,
    Checked Checked,
    bool Cached);

public sealed record Problem(string? Error, string? Fix);

Configure serialization once with JsonSerializerDefaults.Web: camelCase on the way out, case-insensitive on the way in, so both {"email": ...} and the response map correctly:

private static readonly JsonSerializerOptions Json =
    new(JsonSerializerDefaults.Web);

Unknown properties are ignored by default in System.Text.Json, which matters because the taxonomy is frozen for rewording, not for extension — new fields may be added.

Write one check with a hard deadline

Build the HttpClient once and reuse it — in ASP.NET Core, register it through IHttpClientFactory rather than new HttpClient() per request, or you will exhaust sockets under load.

private static readonly Uri CheckUri = new("https://nobounce.dev/v1/check");

public sealed record CheckResult(Verdict? Verdict, Problem? Problem, int Status);

public static async Task<CheckResult> CheckEmailAsync(
    HttpClient client, string key, string email,
    CancellationToken callerToken)
{
    using var timeout = CancellationTokenSource.CreateLinkedTokenSource(callerToken);
    timeout.CancelAfter(TimeSpan.FromSeconds(2));

    using var request = new HttpRequestMessage(HttpMethod.Post, CheckUri);
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
    request.Content = JsonContent.Create(new { email }, options: Json);

    using var response = await client.SendAsync(
        request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
    var body = await response.Content.ReadAsStringAsync(timeout.Token);

    if (!response.IsSuccessStatusCode)
    {
        var problem = JsonSerializer.Deserialize<Problem>(body, Json);
        return new CheckResult(null, problem, (int)response.StatusCode);
    }
    return new CheckResult(JsonSerializer.Deserialize<Verdict>(body, Json), null, 200);
}

CancelAfter bounds the whole exchange, including body read, and is the deadline that matters for signup latency. A canceled request surfaces as OperationCanceledException (TaskCanceledException derives from it); HttpRequestException covers transport failures. Both mean "the validator has no opinion", not "reject the user". Errors come back as RFC 9457 problem details with a stable error and a plain-language fix; log those fields, not the submitted address. nobounce never persists a plaintext address — anything stored is SHA-256, while domains remain in clear.

That error channel is the same fail-open policy covered in Fail Open: Email Validation Must Never Block a Signup.

Map the verdict to a signup action

public enum SignupAction { Accept, Suggest, Reject }

public sealed record Decision(
    SignupAction Action, string Verdict, string Reason,
    string? Suggestion, bool MxChecked);

public static async Task<Decision> DecideAsync(
    HttpClient client, string key, string email)
{
    CheckResult result;
    try
    {
        result = await CheckEmailAsync(client, key, email, CancellationToken.None);
    }
    catch (OperationCanceledException)
    {
        return new Decision(SignupAction.Accept, "unknown", "timeout", null, false);
    }
    catch (HttpRequestException)
    {
        return new Decision(SignupAction.Accept, "unknown", "unreachable", null, false);
    }

    if (result.Verdict is null)
    {
        return new Decision(SignupAction.Accept, "unknown",
            $"http_{result.Status}", null, false);
    }

    var v = result.Verdict;
    var hasSuggestion = v.Suggestion is not null && v.Suggestion.Length > 0;

    return v.Verdict switch
    {
        "undeliverable" when hasSuggestion =>
            new Decision(SignupAction.Suggest, v.Verdict, v.Reason, v.Suggestion, v.Checked.Mx),
        "undeliverable" =>
            new Decision(SignupAction.Reject, v.Verdict, v.Reason, null, v.Checked.Mx),
        _ => new Decision(SignupAction.Accept, v.Verdict, v.Reason, null, v.Checked.Mx),
    };
}
API result application action
deliverable accept
risky accept, then apply your own policy if needed
unknown accept and record checked.mx
undeliverable without a suggestion reject with a reason-specific message
undeliverable with a suggestion ask the user to confirm the correction

risky covers disposable domains and role accounts such as admin@. Those addresses can receive mail, so blocking them is a product rule, not a validity fact. unknown with checked.mx: false is the frozen fail-open signal: DNS did not complete, so the API refuses to invent a deliverable answer.

The Suggest branch is the product. Render "Did you mean user@gmail.com?" with one-click acceptance and a keep-what-I-typed option. Do not silently rewrite the submitted value. The reason field tells you why a correction was computed — typosquat_mx means the typed domain resolves to a known typo-domain mail host, likely_typo means the domain itself was repaired by edit distance. Those mechanics are covered in Suggest the Correction Instead of Rejecting the Signup.

Call it once, on submit

Wire DecideAsync into the controller that creates the account, not into a per-keystroke endpoint. Live DNS on every incomplete string burns quota and races the UI. Persist Verdict, Reason, and MxChecked so you can tell a completed check from a degraded one, and never log the raw address. If a later cleanup job needs to join results back, the batch endpoint keys each row by email_sha256, which also keeps your storage free of address-shaped columns.

Finally, test the deadline path. Point the HttpClient base address at an unreachable host — http://localhost:1/ is enough — and assert DecideAsync returns the Accept/unknown decision within roughly your timeout budget. A happy-path test proves the API works. A deadline test proves your signup survives when it does not. If you retry instead of failing open, read Retry an Email Validation Call the Right Way first.

For the complete key-acquisition, verdict-mapping, and verification procedure, give your coding agent /integrate.md.