Fingerly.Server.AspNetCore 0.1.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Fingerly.Server.AspNetCore --version 0.1.0
                    
NuGet\Install-Package Fingerly.Server.AspNetCore -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Fingerly.Server.AspNetCore" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Fingerly.Server.AspNetCore" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Fingerly.Server.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Fingerly.Server.AspNetCore --version 0.1.0
                    
#r "nuget: Fingerly.Server.AspNetCore, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Fingerly.Server.AspNetCore@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Fingerly.Server.AspNetCore&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Fingerly.Server.AspNetCore&version=0.1.0
                    
Install as a Cake Tool

fingerly-dotnet

Server-side event reads, webhook verification and a same-origin proxy for .NET.

dotnet add package Fingerly.Server
dotnet add package Fingerly.Server.AspNetCore   # for the proxy and DI
using Fingerly;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFingerly(options =>
{
    options.SecretKey = builder.Configuration["Fingerly:SecretKey"]!;
});
var app = builder.Build();

app.MapGet("/orders/{requestId}/check", async (string requestId, FingerlyClient fingerly, CancellationToken ct) =>
{
    Event ev = await fingerly.Events.GetAsync(requestId, ct);
    return ev.IsScored ? Results.Ok(ev.SuspectLevel) : Results.Ok("unscored");
});

app.Run();
EventPage page = await fingerly.Events.ListAsync(
    new EventListOptions { Level = "high", Limit = 50 }, cancellationToken);

Event reads require fly_sk_{production|staging|development}_… keys. Requests go to https://api.fingerly.io; Endpoint overrides that for a local API. Public, proxy, management, malformed and unsupported keys are rejected before network access, by the constructor rather than by the first call. Requests do not follow redirects. GetAsync and ListAsync each perform one request and never retry. An empty, . or .. request id is an ArgumentException.

Packages

Package Contains For
Fingerly.Server client, webhooks, proxy core everything
Fingerly.Server.AspNetCore middleware, endpoint routing, DI the proxy

Fingerly.Server targets net8.0 and netstandard2.0. On net8.0 it has no dependencies at all, because System.Text.Json is part of the shared framework; on netstandard2.0 that one package is the only dependency, which is what keeps the client and webhook verification reachable from .NET Framework 4.6.2 and newer. Fingerly.Server.AspNetCore targets net8.0 and takes ASP.NET Core as a framework reference, so nothing here collides with what your application server already has.

Both packages are marked trimmable and AOT-safe. Every type is bound through the System.Text.Json source generator rather than reflection, so a trimmed or natively compiled application gets no warnings from this SDK and no binding that the trimmer can quietly break.

Everything is asynchronous

There is no synchronous counterpart to any call, on purpose. Blocking a request thread on I/O is what exhausts a thread pool under load, and the deadlock that .Result can cause is not one an SDK could warn about at the call site. Never call .Result or .Wait() on these methods.

Deadlines, limits and connections

Timeout bounds the whole exchange, including receiving the body: a server that sends headers and then trickles cannot hold the call past it. It is applied with a linked CancellationTokenSource rather than by writing to HttpClient.Timeout, so handing the client a shared HttpClient does not rewrite a timeout that belongs to whoever else is using it.

The two reasons a call can stop early stay distinguishable. A deadline throws FingerlyTimeoutException; a CancellationToken that you cancelled raises OperationCanceledException, which is what the caller who cancelled expects to catch. HttpClient reports both as TaskCanceledException on its own, so telling them apart is the SDK's job rather than yours.

An answer larger than 4 MiB is refused rather than buffered, whatever HttpClient sent it: the client throws FingerlyException, the proxy answers 502.

Every client and proxy in a process shares one connection pool, so building one per request still reuses connections, and the pool recycles connections every two minutes so DNS changes are picked up. It keeps no cookie jar, never follows a redirect, and copies none of your application's traceparent, tracestate or baggage onto requests to Fingerly. AddFingerly configures the IHttpClientFactory handler the same way. If you pass your own Handler, give it AllowAutoRedirect = false, UseCookies = false, automatic decompression and ActivityHeadersPropagator = DistributedContextPropagator.CreateNoOutputPropagator().

Errors

Every exception derives from FingerlyException.

try
{
    ev = await fingerly.Events.GetAsync(requestId, ct);
}
catch (FingerlyApiException e) when (e.IsPending)
{
    return "retry"; // not decided yet: read again after e.RetryAfter seconds
}
catch (FingerlyApiException e) when (e.IsEventNotFound)
{
    return "refuse"; // never issued, or past retention
}

FingerlyApiException is thrown for every non-2xx answer, redirects included. It carries Status, and optional Code, Details, RequestId and RetryAfter: the seconds Retry-After asked for, or null. The secret key is stripped from all of them, and a response body is never echoed, not even in an inner exception.

A read answers 404 with one of two codes, each with its own check:

  • IsPending: event_pending, the request id was accepted and the event is still being recorded. Nothing is decided yet: read again after RetryAfter seconds (the API sends Retry-After: 2), or verify asynchronously. Do not treat it as fraud.
  • IsEventNotFound: event_not_found, the request id was never issued, or the event is past retention.

The client never retries by itself. A deadline throws FingerlyTimeoutException; an unreachable API throws FingerlyConnectionException.

No SDK object prints a credential: ToString() shows [redacted] in place of every key, and serializing the options leaves the keys out.

Unscored events

Event.SuspectScore is int?, null when the request was not scored. Never read null as zero: a visitor about whom nothing fired scores 0, while a submission that was never enriched has no score at all. IsScored and TryGetScore state the distinction at the call site.

if (!ev.IsScored)
{
    // Decide on something else; do not treat it as clean.
}

Raw keeps the response body exactly as it arrived, so a field the API adds later is reachable without an SDK upgrade.

Read an event you just identified

An event is usually readable as soon as identification answers. If it is still being recorded, the read throws a FingerlyApiException whose IsPending is true and whose RetryAfter says when to read again:

Event ev;
try
{
    ev = await fingerly.Events.GetAsync(requestId, ct);
}
catch (FingerlyApiException e) when (e.IsPending)
{
    // Not recorded yet, so nothing is decided: read again after
    // e.RetryAfter seconds (2 when null), or verify asynchronously.
    return "retry";
}

Retry a pending read a bounded number of times, for example from a background job. IsEventNotFound means a request id that was never issued, or one past retention, and is never pending.

The archived document arrives moments after the event itself, so check HasDocument rather than assuming it is there.

Verify a backend action

Read the event with a server-only key. Check that its tag matches your action, that it is recent, that its environment is correct, and that it was scored. Apply your own policy to the server's verdict. A pending event (IsPending) is not a verdict: retry after RetryAfter seconds or verify asynchronously, rather than refusing the action. Do not authorize an action using scores or visitor identifiers supplied by the browser. Tags correlate events; they do not replace your application's authentication or replay protection.

Webhooks

string secret = builder.Configuration["Fingerly:WebhookSecret"]!;

app.MapPost("/webhooks/fingerly", async (HttpRequest request, CancellationToken ct) =>
{
    using var body = new MemoryStream();
    await request.Body.CopyToAsync(body, ct);
    byte[] payload = body.ToArray();

    if (!Webhooks.Verify(secret, payload,
            request.Headers["x-fingerly-timestamp"],
            request.Headers["x-fingerly-signature"]))
    {
        return Results.BadRequest();
    }

    WebhookEvent ev = Webhooks.Parse(payload);
    // Queue ev.Id and payload for processing, deduplicating on ev.Id.
    return Results.Ok();
});

Verify the exact raw bytes before parsing them. Reading the body into a string and encoding it again is not the same thing: any byte that is not valid UTF-8 becomes U+FFFD on the way through, and the digest then never matches. Signatures cover the timestamp and body. The default replay tolerance is five minutes; persist processed Id values to deduplicate deliveries. During signing-secret rotation, any matching signature in the comma-separated header is accepted; at most 32 are read. Malformed or missing input is a false result, never an exception.

WebhookEvent.Data stays a JsonElement, so a new event type needs no SDK upgrade.

Same-origin proxy

Content blockers refuse browser requests to third-party APIs, and a refused identification is a visitor never identified. Mount the proxy on a path of your own site and those requests become first-party. The proxy key is what lets that hop declare the visitor's real address, origin and user agent; a public key alone never can, so nobody can spoof them.

Create a proxy in the dashboard (Integration > Proxy keys). It issues the key and a random path such as /k7x2qm4a; use that path as it is.

builder.Services.AddFingerlyProxy(options =>
{
    options.ProxyKey = builder.Configuration["Fingerly:ProxyKey"]!;
    options.Path = "/k7x2qm4a";
    options.ClientIp = request => request.Header("CF-Connecting-IP") ?? request.RemoteAddress;
});

ClientIp is the one thing you must get right. Take the header your CDN or load balancer sets when it is there, and the connection's peer address (RemoteAddress) otherwise. Trust that header only when traffic can reach your server through nothing but that CDN or load balancer. Never read the left-most X-Forwarded-For value: the visitor writes it.

ClientIp must return exactly one IPv4 or IPv6 address. Anything else (null, empty, a list, a port, a host name, or an exception) is a configuration error: the request is refused with a 500 saying "The client IP callback returned no valid address", nothing is sent upstream, and the mistake is logged once.

Then mount it, either as a routed endpoint:

app.MapFingerlyProxy();

or as middleware, which suits an application that does not use endpoint routing:

app.UseFingerlyProxy();

Prefer the mapped endpoint in a minimal-API application: the path becomes an ordinary endpoint, so rate limiting, CORS and output caching apply to it the way they apply to everything else. It is mapped with AllowAnonymous(), so a fallback authorization policy does not send signed-out visitors to a sign-in page: the browser SDK carries no user. Put the middleware ahead of UseAuthentication() for the same reason.

Then point the browser SDK at the same path:

const fingerly = await load({ apiKey: 'fly_pk_…', endpoints: '/k7x2qm4a' })

What the proxy does

The browser SDK posts both of its calls to that one path, and the proxy forwards each to Fingerly's one ingest URL, https://api.fingerly.io/api/v1/ingest (ApiVersion changes the version there and nowhere else). It answers only POST to the path itself, exactly: case-sensitive, and compared with the raw request target as the visitor sent it, so a trailing or doubled slash, a dot segment or percent encoding is a different path. The middleware passes such a request on to your application; the mapped endpoint answers it 404. Anything under the path gets the same, and any other method gets 405. Event reads need a secret key and stay unreachable from a browser by construction.

The browser must present a public key from the same environment as the proxy key, or the request gets 401. Bodies over 1 MiB get 413, enforced while reading rather than after buffering. Only the content type, the visitor metadata Fingerly needs, and an Idempotency-Key are forwarded; customer cookies, authorization headers and trace context never are. The proxy reports itself upstream as dotnet/0.1.0, which the dashboard shows per proxy.

Only Content-Type, X-Request-Id, RateLimit-Limit, RateLimit-Remaining and Retry-After are relayed back, with Cache-Control: no-store added. A blocker can match a response header by name, so this is an allowlist: nothing on the response names Fingerly, whatever the API adds later. The answer is read whole before anything is relayed, so a truncated one is never passed on as a success.

When the upstream fails, the browser gets 504 ("Gateway timeout") if it did not answer within Timeout (5 seconds by default), and 502 ("Bad gateway") for anything else: refused, reset, truncated, larger than 4 MiB, or undecodable. One line is logged per failure, without a stack trace or a key: AddFingerlyProxy sends it to the application's ILogger (category Fingerly.FingerlyProxy) at warning level, and FingerlyProxyOptions.Log takes any other sink. A visitor who disconnects cancels the upstream call, and the request ends quietly.

The middleware asks proxy.Handles(path) before reading anything. It sees every request in the pipeline, and calling HandleAsync to find out whether one belongs to Fingerly would have consumed a request body the application still needs. In ASP.NET Core that body is a forward-only stream unless the application opted into buffering, so the mistake would be silent rather than loud. For the same reason the middleware refuses a proxy with no Path.

Path is the path within your application. It excludes anything UsePathBase or an IIS virtual directory adds, because the proxy matches Request.Path, which is application-relative in every host. It is optional where routing already decides the path: leave it unset and give the path to MapFingerlyProxy instead, which also works inside a route group.

app.MapGroup("/shop").MapFingerlyProxy("/k7x2qm4a");

Setting it in both places is refused, so the two cannot disagree. Use the path the dashboard issued rather than one of your own: blockers look for words such as fingerprint, tracking and analytics.

Outside ASP.NET Core

FingerlyProxy speaks to no server API. It reads a ProxyRequest (with RemoteAddress set to the connection's peer) and returns a ProxyResponse, so an isolated-worker Azure Function, a YARP transform or an OWIN pipeline can mount it by translating those two shapes. Where that host routes only the one path to it, Path can stay unset. That is also why the core still runs on .NET Standard 2.0.

Development and release preparation

dotnet build                                   # both target frameworks, warnings as errors
dotnet test                                    # against a real Kestrel server
dotnet pack -c Release -o artifacts
dotnet publish tools/TrimCheck -c Release -r osx-arm64 -o trimmed && ./trimmed/TrimCheck

FINGERLY_CONTRACT_DIR=../../contract dotnet run --project tools/SyncContract

Warnings are errors, and XML documentation is generated, so an undocumented public member fails the build the way an unused variable does. A warning the SDK ships is one its users cannot fix.

The tests drive a real Kestrel server rather than a stubbed HttpMessageHandler or HttpContext. A double shares the SDK's assumptions about framing, path bases and bodies that can only be read once, so it cannot disprove any of them.

The vendored synthetic contract snapshot makes tests independent of sibling repositories. Its hashes are checked by ContractTest, so there is no separate check step. Never put production secrets in fixtures.

Source structure

FingerlyClient holds the options; EventsService owns the reads and the list filters, and Event, EventPage and EventListOptions are what they speak in. Webhooks verifies signatures. FingerlyProxy holds every proxy decision, with ProxyRequest and ProxyResponse as the neutral shapes the hosts translate to. SharedHttp is the process-wide connection pool and reads every answer within its limit. Keys validates every caller-supplied option before any network access, and Json keeps the serializer context in one place so a caller's own JsonSerializerOptions cannot change how responses are read.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 42 11/12/2026
0.1.0 61 11/10/2026