Fingerly.Server reads stored events by request ID, verifies webhook signatures, and forwards the browser SDK's requests through a route on your own domain. On .NET 8 it has no runtime dependencies at all.
Requirements
- .NET 8 or newer. .NET Standard 2.0 is also targeted, so event reads and webhook verification work on .NET Framework 4.6.2 and newer.
- A secret key for reading events, a webhook signing secret, and a proxy key for the proxy.
Install
dotnet add package Fingerly.Server
dotnet add package Fingerly.Server.AspNetCore # for the proxy
Read an event
Create one client with your secret key and reuse it. The key names the environment the client reads.
using Fingerly;
var fingerly = new FingerlyClient(Environment.GetEnvironmentVariable("FINGERLY_SECRET_KEY")!);
var ev = await fingerly.Events.GetAsync("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4");
An event has the fields listed in Get an event. suspect_score is null when the request was not scored.
Verify a checkout
Read the event your client identified, check it belongs to this action and is recent, then decide on its level. See server-side verification.
public async Task<string> DecideAsync(string orderId, string requestId, CancellationToken ct)
{
Event ev;
try
{
ev = await _fingerly.Events.GetAsync(requestId, ct);
}
catch (FingerlyApiException e) when (e.Status == 404)
{
return "refuse";
}
if (ev.Tag != $"checkout:{orderId}") return "refuse";
if (DateTimeOffset.UtcNow - ev.OccurredAt > TimeSpan.FromMinutes(2)) return "refuse";
return ev.SuspectLevel switch
{
"high" => "review",
"medium" => "challenge",
_ => "allow",
};
}
Verify a webhook
Check the signature over the raw request body before parsing it. The helper rejects timestamps more than five minutes from now.
app.MapPost("/webhooks/fingerly", async (HttpRequest request, IEventQueue queue) =>
{
// The raw bytes, not a decoded string: re-encoding changes the digest.
using var buffered = new MemoryStream();
await request.Body.CopyToAsync(buffered);
var payload = buffered.ToArray();
var valid = Webhooks.Verify(
secret: builder.Configuration["Fingerly:WebhookSecret"]!,
payload: payload,
timestamp: request.Headers["x-fingerly-timestamp"],
signature: request.Headers["x-fingerly-signature"]);
if (!valid) return Results.BadRequest();
var ev = Webhooks.Parse(payload);
await queue.EnqueueAsync(ev.Id, payload); // deduplicate on the event ID
return Results.NoContent();
});
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 declares no dependencies, because System.Text.Json is part of the shared framework; on netstandard2.0 that one package is the only dependency. Fingerly.Server.AspNetCore takes ASP.NET Core as a framework reference, so nothing collides with what your application server already has.
Dependency injection
AddFingerly registers the client through IHttpClientFactory and configures its primary handler never to follow redirects. It returns the ordinary IHttpClientBuilder, so a resilience or logging handler can be added to it in the usual way.
builder.Services.AddFingerly(options =>
{
options.SecretKey = builder.Configuration["Fingerly:SecretKey"]!; // fly_sk_production_…
});
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 .Result can cause is not one an SDK could warn about at the call site.
Deadlines and cancellation
Timeout bounds the whole exchange, including receiving the body. 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.
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, and Raw keeps the response body so a field the API adds later needs no SDK upgrade.
Read an event you just identified
An event is readable as soon as identification answers, so this needs nothing special.
Event ev = await fingerly.Events.GetAsync(requestId, ct);
Earlier versions had a WaitForAsync that polled through the gap between identification and the event becoming queryable. There is no gap, so IsEventNotFound now means a request id that was never issued. The archived document arrives moments after the event itself, so check HasDocument rather than assuming it is there.
Serve the browser SDK from your domain
Content blockers refuse browser requests to third-party APIs, and a refused identification is a visitor never identified. Mount the proxy at the path the dashboard issued for it, in Integration > Proxy keys, and those requests become first-party. See proxy integrations.
FingerlyProxy holds every decision and speaks to no server API. Register it once, then mount it as routed endpoints or as middleware.
builder.Services.AddFingerlyProxy(options =>
{
options.ProxyKey = builder.Configuration["Fingerly:ProxyKey"]!; // fly_px_production_…
options.Path = "/k7x2qm4a";
options.ClientIp = request => request.Header("CF-Connecting-IP");
});
var app = builder.Build();
app.MapFingerlyProxy();
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.
With Path set, the proxy answers only that path; the middleware passes every other request on to your application. It answers 405 to anything but POST, and 401 when the request carries no public key for the same environment as the proxy key. Every request is forwarded to https://api.fingerly.io/api/v1/ingest, and only content-type, x-request-id and the rate limit headers are relayed back, so nothing on the response names Fingerly. Event reads need a secret key and are unreachable through it. Responses are marked no-store, customer cookies and authorization headers are never forwarded, and the proxy reports itself upstream as dotnet/0.1.0, which the dashboard shows per proxy.
API
FingerlyOptions
SecretKeystringrequiredA secret key. Its prefix names the environment read.Endpointstring?Override the API origin.ApiVersionstringDefault"v1"API route version; the server must support the selected version.TimeoutTimeSpanDefault10sDeadline per request, covering the response body.HandlerHttpMessageHandler?A custom transport. Give itAllowAutoRedirect = false.
| Method | Returns | Calls |
|---|---|---|
Events.GetAsync(requestId, ct) | Task<Event> | GET /events/{request_id}. |
Events.ListAsync(options, ct) | Task<EventPage> | GET /events. options may be null; an unset filter is not sent, so the server defaults it. |
Webhooks.Verify(secret, payload, timestamp, signature) | bool | Static, over the raw bytes. Never throws for malformed input; a five-minute replay window by default. |
Webhooks.Parse(payload) | WebhookEvent | The delivery envelope. Data stays a JsonElement. |
new FingerlyProxy(options) | FingerlyProxy | The proxy core, mounted by the ASP.NET Core package. |
Every exception derives from FingerlyException. A non-2xx response throws FingerlyApiException with Status, and optional Code, Details and RequestId; the secret key is stripped from all three. A deadline throws FingerlyTimeoutException and an unreachable API throws FingerlyConnectionException.