There is no .NET SDK. For an API with one endpoint that matters, the wrapper is a small typed HttpClient and a record — maybe eighty lines including the failure handling. Writing it yourself means the timeout and the fail-open policy are decisions you made rather than defaults you inherited.

Two things in that eighty lines are where .NET integrations actually go wrong, and neither has anything to do with fraud: how the HttpClient is registered, and how the JSON maps onto your record. Everything else is straightforward.

Written for .NET 8 or later, using primary constructors and minimal APIs. The MVC equivalents are noted where they differ.

Register the client, do not new it up

The two obvious ways to get an HttpClient are both wrong, in opposite directions. A new HttpClient() per request exhausts sockets under load: each instance holds its connections in TIME_WAIT long after the response, and a busy signup endpoint runs the machine out of ephemeral ports. A static readonly HttpClient for the lifetime of the process fixes that and introduces a quieter problem — it never picks up DNS changes, so when the vendor moves an endpoint your app keeps dialling an address that no longer answers.

IHttpClientFactory exists precisely for that pair. Register a typed client at startup:

// Program.cs
var key = builder.Configuration["Sentinel:ApiKey"];

builder.Services.AddHttpClient<SentinelClient>(c =>
{
    c.BaseAddress = new Uri("https://maskbreak.com/");
    // Short. This sits in front of a user-facing action, so a slow
    // answer is worth less than no answer.
    c.Timeout = TimeSpan.FromSeconds(1.5);
    c.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", key);
});

The key comes from configuration — user secrets in development, an environment variable or key vault in production. It is a server-side credential and it must never reach the browser; the frontend collector needs no key at all.

The client

One class, one method. The interesting lines are the two catch clauses.

// Services/SentinelClient.cs
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;

public sealed record Verdict(
    [property: JsonPropertyName("decision")]   string Decision,
    [property: JsonPropertyName("risk_score")] int RiskScore,
    [property: JsonPropertyName("reasons")]    string[]? Reasons)
{
    public bool IsBlocked    => Decision == "block";
    public bool IsSuspicious => Decision != "allow";
}

public sealed class SentinelClient(
    HttpClient http, ILogger<SentinelClient> log)
{
    public async Task<Verdict?> EvaluateAsync(
        string token, string? accountId, CancellationToken ct)
    {
        var body = new Dictionary<string, string> { ["token"] = token };
        if (accountId is not null) body["accountId"] = accountId;

        try
        {
            using var res =
                await http.PostAsJsonAsync("v1/evaluate", body, ct);
            if (!res.IsSuccessStatusCode)
            {
                var code = (int)res.StatusCode;
                log.LogWarning("sentinel http {Status}", code);
                return null;
            }
            return await res.Content.ReadFromJsonAsync<Verdict>(ct);
        }
        catch (TaskCanceledException) when (ct.IsCancellationRequested)
        {
            // The caller went away. Not a vendor problem — do
            // not swallow it and do not log it as an outage.
            throw;
        }
        catch (Exception ex) when (ex is HttpRequestException
                                      or TaskCanceledException
                                      or JsonException)
        {
            // Fail open. A detection outage must not become a
            // checkout outage.
            log.LogWarning(ex, "sentinel unavailable");
            return null;
        }
    }
}

The ordering of those two catches matters. HttpClient surfaces its own timeout as a TaskCanceledException, which is the same exception type you get when the browser disconnects mid-request. Filtering on ct.IsCancellationRequested is what separates "the vendor was slow, continue without a verdict" from "nobody is listening any more, stop working". Collapse them into one catch and your logs will show a vendor incident every time someone closes a tab.

The field that silently deserialises to zero

The response uses snake_case: decision, risk_score, reasons. System.Text.Json in ASP.NET Core is case-insensitive by default, which fools people into thinking the mapping is automatic. It is not: case insensitivity does nothing about an underscore, so a property called RiskScore matches nothing in risk_score and lands on its default value.

The failure is quiet, which is what makes it worth a section. There is no exception. Every verdict simply arrives with a risk score of 0, your threshold logic never fires, and the integration looks like it is working — a fraud check that always says the traffic is clean is indistinguishable from a fraud check that is switched off.

Two ways to fix it. Either annotate, as above, or set a naming policy on .NET 8 or later:

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};

Prefer the attributes. The reason codes and field names on this API are a stability promise — proxy_detected and datacenter_asn will not be renamed under you — and an explicit JsonPropertyName ties your record to that contract in a way a global policy applied to every other API in your solution does not.

An endpoint filter, not middleware

Middleware runs on every request, including your static files, your health check and your Swagger UI, so you end up maintaining path prefixes to keep it off the fast path. An endpoint filter attaches to the specific routes where a decision is worth making, and takes parameters.

// Filters/ScreenFilter.cs
public sealed class ScreenFilter(bool strict) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx,
        EndpointFilterDelegate next)
    {
        var http   = ctx.HttpContext;
        var client = http.RequestServices
            .GetRequiredService<SentinelClient>();

        var token = http.Request.Headers["X-Sentinel-Token"].ToString();
        if (string.IsNullOrEmpty(token))
        {
            // An ad blocker or a CSP mistake can legitimately stop the
            // collector. Degraded, not hostile — unless this is money.
            if (strict)
            {
                var err = new { error = "missing security token" };
                return Results.BadRequest(err);
            }
            return await next(ctx);
        }

        var verdict = await client.EvaluateAsync(
            token,
            http.User.FindFirst("sub")?.Value,
            http.RequestAborted);

        // null means no verdict: no token, vendor down, bad JSON.
        if (verdict is null) return await next(ctx);

        var refuse = strict ? verdict.IsSuspicious : verdict.IsBlocked;
        if (refuse)
        {
            var reasons = verdict.Reasons ?? [];
            return Results.Json(
                new { error = "blocked", reasons },
                statusCode: StatusCodes.Status403Forbidden);
        }

        // Hand the verdict to the route handler for the review case.
        http.Items["verdict"] = verdict;
        return await next(ctx);
    }
}

Now the policy is one readable line per endpoint, sitting next to the route it governs:

// Signup: refuse only a hard block. VPN users are customers.
app.MapPost("/api/signup", SignupHandler)
   .AddEndpointFilter(new ScreenFilter(strict: false));

// Money leaving: refuse anything that is not clean.
app.MapPost("/api/withdraw", WithdrawHandler)
   .AddEndpointFilter(new ScreenFilter(strict: true));

// Checkout: read the verdict; review costs a step, not the account.
app.MapPost("/api/checkout", async (
    CheckoutRequest req, HttpContext http) =>
{
    var verdict = http.Items["verdict"] as Verdict;
    // review costs a step: OTP, 3DS, manual queue.
    if (verdict?.Decision == "review") return await StepUpAsync(req);
    return await ChargeAsync(req);
})
.AddEndpointFilter(new ScreenFilter(strict: false));

On controllers the same shape is an IAsyncActionFilter registered with [ServiceFilter] or [TypeFilter(typeof(ScreenFilter), Arguments = [true])], short-circuiting by assigning context.Result. The reasoning does not change.

The rule worth holding onto: review must not route to the same place as block. If it does, you have built a hard block with extra steps, and every VPN user in your funnel is a refused customer who will never tell you. There is more on that split in route on the decision, sort on the score.

Pass an account id and get multi-accounting for free

The second argument to EvaluateAsync is doing more than logging. When an account id is present, the same device arriving under a second account becomes linkable, and the response carries device.linked_accounts plus a multi_account_device reason code. That is the signal that catches trial abuse, referral farming and bonus abuse — none of which look wrong on any single request.

Two constraints before you wire it up. Linking is per-customer and hash-only: your accounts are never linked against another customer's, and neither the raw device identifier nor your account id is stored in the clear. And it only works with a stable id. http.User.FindFirst("sub") above is the user's subject claim, which is stable; a session id or an ASP.NET Core anti-forgery token changes constantly and links nothing.

Testing

The public sandbox key returns documented responses for a fixed set of tokens, with no account and no live pipeline behind it:

curl -X POST https://maskbreak.com/v1/evaluate \
  -H "Authorization: Bearer sk_test_sandbox" \
  -H "Content-Type: application/json" \
  -d '{"token":"test_proxy"}'

# token           decision risk_score reasons
# test_clean      allow             0 []
# test_datacenter allow            15 [datacenter_asn]
# test_tor        block            15 [tor_exit_node, anonymous_network]
# test_vpn        review           65 [vpn_detected, datacenter_asn]
# test_proxy      block            80 [proxy_detected, datacenter_asn]

Point a WebApplicationFactory at that key and the fraud paths become ordinary integration tests:

public class ScreeningTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    private HttpClient Client() => factory
        .WithWebHostBuilder(b =>
            b.UseSetting("Sentinel:ApiKey", "sk_test_sandbox"))
        .CreateClient();

    [Fact]
    public async Task Withdraw_IsRefused_ForAResidentialProxy()
    {
        var client = Client();
        client.DefaultRequestHeaders
            .Add("X-Sentinel-Token", "test_proxy");

        var body = new { amount = 100 };
        var res = await client.PostAsJsonAsync("/api/withdraw", body);

        Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode);
    }
}

For the outage path, do not mock the network — add a DelegatingHandler to the typed client in your test host that throws HttpRequestException, and assert that the withdrawal still goes through:

b.ConfigureServices(s => s
    .AddHttpClient<SentinelClient>()
    .AddHttpMessageHandler(() => new ThrowingHandler()));

That is the test people leave out, and it is the one that documents your actual policy. Notice what it asserts: when the fraud check is unavailable, money still moves. If that makes you uncomfortable, the fix is a lower rate limit and a review queue, not a check that fails closed.

The short version

  • Register a typed client with AddHttpClient. Never new HttpClient() per request, never a permanent static one.
  • Map risk_score explicitly with JsonPropertyName — case insensitivity does not cover underscores, and the failure is a silent zero.
  • Rethrow TaskCanceledException when ct.IsCancellationRequested; treat every other failure as allow.
  • Endpoint filter, not middleware. Strict on money, hard-block-only on signup, nothing on static routes.
  • Route review to a step-up rather than a refusal.
  • Pass a stable claim as the account id if multi-accounting is part of your problem.
  • Test verdicts with the sandbox key; test the outage path with a handler that throws.
FAQ

Frequently Asked Questions

Is there a .NET SDK for Maskbreak?

No. The official SDKs are Node, Python and PHP. For .NET the integration is a typed HttpClient registered with AddHttpClient plus a record for the response — roughly eighty lines including error handling — which is enough for an API with one endpoint that matters, and it keeps the timeout and failure policy in your own code.

Why is risk_score always 0 in my C# integration?

The property name almost certainly does not match. System.Text.Json is case-insensitive in ASP.NET Core but that does nothing about the underscore, so a RiskScore property never binds to risk_score and takes its default value of zero. Annotate with [JsonPropertyName("risk_score")], or set PropertyNamingPolicy to JsonNamingPolicy.SnakeCaseLower on .NET 8 or later. There is no exception, so the integration looks healthy while every verdict reads as clean.

Middleware or an endpoint filter for fraud checks in ASP.NET Core?

An endpoint filter in almost every case. Middleware runs on every request including static files, health checks and Swagger, so you maintain path prefixes to keep it off the fast path. A filter attaches to the routes where a decision is worth making, takes parameters such as strictness, and short-circuits by returning a result. On controllers the equivalent is an IAsyncActionFilter.

How should a fraud check handle a timeout in ASP.NET Core?

Allow the request. Set HttpClient.Timeout to one or two seconds on the typed client, then catch HttpRequestException, TaskCanceledException and JsonException and return no verdict. One exception: rethrow TaskCanceledException when the request CancellationToken is already cancelled, because that is the caller disconnecting rather than a vendor problem, and swallowing it turns every closed tab into a false outage in your logs.

Eighty lines and a free key

The sandbox key needs no account at all. When you want live verdicts, the free tier is 1,000 requests per hour with no card.

Try Maskbreak free →