---
title: "How to Validate an Email Address in C#"
description: "Call /v1/check from .NET 8 with HttpClient and System.Text.Json, per-request timeouts, records for the frozen verdict object, and fail-open signup handling."
slug: "validate-email-in-csharp"
date: 2026-10-09
updated: 2026-10-09
last_tested: 2026-10-09
summary: "Validate signup emails from C# with the framework's own HttpClient and System.Text.Json, map all four verdicts, and recover mistyped domains instead of rejecting the user."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "HttpClient class — .NET API documentation"
    url: "https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient"
  - title: "JSON serialization with System.Text.Json"
    url: "https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/overview"
  - title: "Records — C# language reference"
    url: "https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record"
  - title: "Make HTTP requests with IHttpClientFactory"
    url: "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-requests"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

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

```bash
curl -X POST https://nobounce.dev/demo/check \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@gmai.com"}'
```

```json
{
  "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.

```bash
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.

```csharp
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:

```csharp
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:

```csharp
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.

```csharp
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](https://nobounce.dev/blog/fail-open-email-validation-signup/).

## Map the verdict to a signup action

```csharp
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](https://nobounce.dev/blog/suggest-the-correction-not-rejection/).

## 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](https://nobounce.dev/blog/retry-email-validation-with-idempotency-key/) first.

For the complete key-acquisition, verdict-mapping, and verification procedure, give your coding agent [/integrate.md](https://nobounce.dev/integrate.md).
