Read identification verdicts, verify signed webhooks and turn a Risk Score into a decision in your .NET backend.
ShieldLabs identifies visitors and scores risk with device intelligence and 300+ risk signals. This package is the server half of an integration. New to ShieldLabs? Start free.
- Browser. The ShieldLabs agent runs an identification on your page (with
@shieldlabs-ai/jsor a framework binding) and gives the page arequestId. The browser never sees a Risk Score. - Your backend. The page sends the
requestIdalong with the protected action (signup, login, checkout). Your backend reads the verdict for it from the History API with this SDK, or receives it as a signedidentification.scoredwebhook. - Decision. Your backend acts on the Risk Score, its band (trusted 0-29, suspicious 30-59, dangerous 60-100), the detection flags and the identifiers, for example how many accounts one device ID has used.
dotnet add package ShieldLabsThe package targets net8.0 and netstandard2.0. Keys come from the analytics dashboard under
Integration > API keys; keep them on your server.
Both server-side halves in one ASP.NET Core app. Create it with dotnet new web, run
dotnet add package ShieldLabs, replace Program.cs with the code below, set
SHIELDLABS_API_KEY (Private API Key, sec_...) and SHIELDLABS_WEBHOOK_SECRET (endpoint signing
secret, whsec_...), then dotnet run.
using System.Collections.Concurrent;
using ShieldLabs;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// One client per domain, shared by every request: it is safe for concurrent use.
var shieldlabs = new ShieldLabsClient(new ShieldLabsClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("SHIELDLABS_API_KEY"), // sec_your_private_key
});
var webhookSecret = Environment.GetEnvironmentVariable("SHIELDLABS_WEBHOOK_SECRET") ?? ""; // whsec_your_signing_secret
// One identification authorizes one action. This in-memory store keeps the example short; in
// production, claim request IDs atomically in a shared store (Redis SET NX, a unique key).
var usedRequestIds = new ConcurrentDictionary<string, DateTimeOffset>();
// 1. The page posts the requestId it received from the browser agent with the signup form.
app.MapPost("/signup", async (SignupForm form, CancellationToken cancellationToken) =>
{
Identification? identification;
try
{
// Scoring is asynchronous: this polls until the verdict is stored (10-second budget by default).
identification = await shieldlabs.Identifications.GetAsync(form.RequestId ?? "", cancellationToken: cancellationToken);
}
catch (ValidationException)
{
return Results.BadRequest(new { error = "requestId must be a UUID" });
}
catch (ShieldLabsException)
{
// Network, key or rate-limit problem: the action stays unverified.
return Results.Json(new { error = "unverified" }, statusCode: 503);
}
// 2. Missing, reused, stale, rate-limited, automated or dangerous: refuse.
var firstUse = identification is not null && usedRequestIds.TryAdd(identification.RequestId, DateTimeOffset.UtcNow);
var evaluation = Risk.Evaluate(identification, new EvaluateOptions { IsReplay = _ => !firstUse });
if (!evaluation.Ok)
{
return Results.Json(new { error = "refused", reason = evaluation.Reason }, statusCode: 403);
}
// Create the account here.
return Results.Ok(new { ok = true, band = evaluation.Band });
});
// 3. Verify each webhook delivery over the raw body before reading it.
app.MapPost("/webhooks/shieldlabs", async (HttpRequest request) =>
{
using var body = new MemoryStream();
await request.Body.CopyToAsync(body);
WebhookEvent evt;
try
{
evt = WebhookEvents.ConstructEvent(body.ToArray(), request.Headers[WebhookSignature.HeaderName], webhookSecret);
}
catch (SignatureVerificationException)
{
return Results.Unauthorized();
}
catch (WebhookParseException)
{
return Results.BadRequest();
}
if (evt is IdentificationScoredEvent scored)
{
// Handle each request ID once: future retries resend identical bytes.
app.Logger.LogInformation("{RequestId}: risk score {RiskScore}", scored.Data.RequestId, scored.Data.RiskScore);
}
return Results.Ok();
});
app.Run();
record SignupForm(string? RequestId, string? Email);POST /signup with {"requestId":"..."} answers 200 with the band, 403 with a reason
(missing, replayed, stale, rate_limited, no_device_signals, blocked_flag,
blocked_band), 400 for a malformed request ID and 503 when the verdict could not be read. A
fuller app with the same routes lives in examples/MinimalApi.
The History row of an identification appears about 1 to 3 seconds after the browser call and can be
refined for up to about 10 seconds while follow-up checks finish (network checks such as the local
IP arrive in later versions of the row). Start the identification when the user begins the action,
for example when the signup form gets focus, so the verdict is usually stored by the time the form
is submitted. Identifications.GetAsync polls the History API by request_id until the row
appears and returns the first version it sees. Timeout (10 seconds by default) is the total time
budget of the call:
- the first lookup is immediate, then the waits grow: 1, 2, 4, 6 and then 8 times
PollInterval, each capped at 2 s, or atPollIntervalwhen that is longer (with the default 250 ms: 250 ms, 500 ms, 1 s, 1.5 s, then every 2 s; 1 s waits 1, 2, 2, 2 s; 3 s polls every 3 s). A wait that would pass the deadline is cut short, so the last lookup runs at the deadline; - each lookup is exactly one HTTP attempt (the client retries do not apply) with the timeout
min(client
Timeout, max(time left, 1 s)), so the call returns at most about 1 second afterTimeout; - a 429, a 5xx, a timeout or a connection error does not end the wait: the next lookup follows the
schedule. Inside the wait a 429 is always followed by at least 1 second before the next lookup:
the longest of the scheduled wait, 1 second and
Retry-Aftercapped at 10 seconds (Retry-After: 0or a date in the past counts as 0, so it still waits 1 second), cut short at the deadline like any other wait; when the cappedRetry-Afteris longer than the time left, theRateLimitExceptionis thrown at once. Calls outside the wait followRetry-Afteras sent (see Errors and retries); - a 400, 401, 403 or 404 stops at once with
BadRequestException,AuthenticationExceptionorNotFoundException: a wrong key or base URL does not heal.
var identification = await shieldlabs.Identifications.GetAsync(
requestId,
new GetIdentificationOptions { Timeout = TimeSpan.FromSeconds(5) },
cancellationToken);
// One lookup, no waiting (for example in a background job), with the client retries:
var latest = await shieldlabs.Identifications.GetAsync(requestId, new GetIdentificationOptions { Wait = false });When time runs out and the last lookup failed (for example with a 429 or a 5xx), that exception is
thrown instead of returning null. null means no identification was found in time: treat it as
unverified, never as clean. It also covers an identification that was never stored, for example
when the visitor's IP went over the per-IP rate limit of identifications: the browser still gets a
request ID, but no row is written for it. To read the refined row later, call GetAsync with
Wait = false again after about 10 seconds.
Risk.Evaluate applies the guard logic most integrations need, in this order: missing identification,
replayed request ID, older than MaxAge (5 minutes), rate-limit marker, no usable device signals,
blocked flags (browser_automation, javascript_disabled), blocked bands (Dangerous). The
defaults are a starting point: tune them to your product.
// Claim the request ID first with an atomic insert-if-absent (Redis SET NX, a unique key), so two
// concurrent requests with the same ID cannot both pass.
bool claimed = identification is not null && await replayStore.TryClaimAsync(identification.RequestId);
var evaluation = Risk.Evaluate(identification, new EvaluateOptions
{
MaxAge = TimeSpan.FromMinutes(2),
BlockBands = new[] { RiskBand.Dangerous },
BlockFlags = new[] { DetectionFlagNames.BrowserAutomation, DetectionFlagNames.AntiDetectBrowser },
IsReplay = _ => !claimed,
});
switch (evaluation.Reason)
{
case null: /* allow */ break;
case EvaluationReason.BlockedBand: /* step up or review */ break;
default: /* refuse */ break;
}IsReplay answers synchronously, so with an asynchronous store claim the request ID first and pass
the result, as above. A lookup followed by a separate write lets two concurrent requests with the
same ID both pass. BlockFlags takes the names in DetectionFlagNames; any other name throws
ValidationException, so a typo never switches a block off.
Working with the fields directly:
Risk.Band(score)returnsTrusted(0-29),Suspicious(30-59) orDangerous(60-100). A value above 100 (999) is a rate-limit marker, never a score:Risk.BandreturnsRateLimitedandRisk.IsRateLimited(score)is true.- Branch on
DetectionFlags(19 booleans such asVpn,Proxy,Tor,AntiDetectBrowser,BrowserAutomation) and onRiskScore.Signalsexplain the score for display and logging; weights can be negative, so never sum them yourself. - A
DeviceIdof00000000-0000-0000-0000-000000000000means "no usable device signals". - Country values are English country names, such as
GermanyorUnited States.
History.SearchAsync reads one page of identifications by one identifier, newest first.
History.IterateAsync pages lazily through all of them and removes rows repeated by offset paging.
// User HID values that do not name one of your accounts (null is skipped as well).
var notAnAccount = new HashSet<string>(StringComparer.Ordinal) { "anonymous", "fail", "-1", "unknown" };
// How many accounts has this device been used with? The all-zero device ID groups identifications
// without device signals, so skip it.
if (identification.DeviceId != "00000000-0000-0000-0000-000000000000")
{
var accounts = new HashSet<string>(StringComparer.Ordinal);
await foreach (var row in shieldlabs.History.IterateAsync(
LookupType.DeviceId, identification.DeviceId, new HistoryIterateOptions { MaxItems = 500 }))
{
if (row.UserHid is { } hid && !notAnAccount.Contains(hid))
{
accounts.Add(hid);
}
}
if (accounts.Count >= 3)
{
// Route the signup to review.
}
}
// One page by User HID, IP or visitor:
HistoryPage page = await shieldlabs.History.SearchAsync(LookupType.UserHid, userHid, new HistorySearchOptions { Limit = 50 });
Console.WriteLine($"{page.Total} identifications for this account");Lookup types: Ip (dotted IPv4), UserHid, VisitorId, RequestId, DeviceId, SessionId,
CookieId (UUIDs, any version, sent lowercase). Arguments are validated on the client before
anything is sent, so a mistyped identifier fails fast with ValidationException instead of
returning unrelated rows. Limit is 1 to 100 (default 20) and Offset is 0 or more. Each request
counts toward the History API rate limit.
A User HID is sent as one URL path segment in the canonical form the History API matches, so
values with @, +, = or spaces match exactly. A value that contains / (standard base64 text
can), and the values . and .., cannot be searched and throw ValidationException. User HIDs
from UserHid.FromUserId are 64 hex characters and always work.
ShieldLabs signs every delivery with the endpoint secret: X-Shield-Signature: sha256=<hex HMAC-SHA256>
over the raw body. WebhookEvents.ConstructEvent verifies the signature, then returns one of:
| Type | When |
|---|---|
IdentificationScoredEvent |
identification.scored: Data is an Identification, the same model the History API returns |
WebhookPingEvent |
webhook.ping, sent by Verify in the analytics dashboard |
UnknownWebhookEvent |
any other event type: acknowledge it with a 2xx and ignore it |
Rules for a reliable receiver:
- Verify the raw bytes as received. Never parse and re-serialize the JSON before verifying.
- Today each identification is delivered once per enabled endpoint: one attempt with a 1-second timeout and no retries. Respond with a 2xx quickly and do slow work in the background.
- Future retries will resend identical bytes, so make the handler idempotent on
Data.RequestId(data.request_idin the body) now: store it and skip deliveries you have already handled. The signature header is the only ShieldLabs header, so the idempotency key comes from the body. - For guaranteed reads, use the History API: a missed delivery is not sent again, and a History row can be refined after its webhook was sent.
- To rotate the signing secret without downtime, pass both secrets:
WebhookEvents.ConstructEvent(body, header, newSecret, oldSecret).
WebhookSignature.Verify(body, header, secret) returns a boolean when you only need the check.
Both accept the body as byte[] or string.
The Management API returns the profile of one registered domain, including the remaining included identifications of the account (negative when the account is over its included volume).
var management = new ManagementClient(new ManagementClientOptions
{
SecretKey = Environment.GetEnvironmentVariable("SHIELDLABS_SECRET_KEY"),
Domain = Environment.GetEnvironmentVariable("SHIELDLABS_DOMAIN"), // example.com
});
DomainProfile profile = await management.GetProfileAsync();
Console.WriteLine($"{profile.Domain}: {profile.RemainingIdentifications} identifications left");The domain is normalized before sending (lowercase, no scheme, path or leading www.), because the
server matches the registered domain exactly. Call this API sparingly and cache the result (see
Rate limits).
Pass a stable, irreversible identifier to the browser agent instead of an email address or a raw account ID:
// userHidSecret: a random secret you generate once and keep on your server.
string userHid = UserHid.FromUserId(user.Id, userHidSecret);It is HMAC-SHA256 of the user ID keyed with your secret, as 64 lowercase hex characters. Keep the
secret stable: changing it changes every User HID. Search the history of that account later with
LookupType.UserHid.
Both clients are safe for concurrent use: create one per domain and reuse it. Without an
HttpClient argument they share one process-wide HttpClient. To use IHttpClientFactory
(proxies, handlers, logging), register the client as a typed client:
builder.Services.AddHttpClient<ShieldLabsClient, ShieldLabsClient>((http, services) => new ShieldLabsClient(
new ShieldLabsClientOptions { ApiKey = builder.Configuration["SHIELDLABS_API_KEY"] },
http));The SDK never disposes an HttpClient you pass in. For development and tests, point the clients
at another origin with BaseUrl (for example from SHIELDLABS_API_BASE_URL or
SHIELDLABS_MANAGEMENT_BASE_URL). Base URLs must use https, because every request carries a key;
plain http is accepted only for localhost, 127.0.0.1 and ::1. The History API origin is
https://account.shieldlabs.ai; a trailing /api in BaseUrl is removed, because request paths
start with /api/v1/. Keys and the Management domain must be ASCII (use the punycode form of an
internationalized domain).
| API | Limit | What the SDK does |
|---|---|---|
| History API | about 15 requests per second per domain, shared by all callers of that domain | retries a 429 after Retry-After as sent (up to 10 s; Retry-After: 0 or a date in the past retries at once), or after at least 1 s when there is no Retry-After; while GetAsync waits, a 429 means "wait longer": at least 1 s, longer when the schedule or Retry-After asks (up to 10 s) |
| Management API | about 15 requests per minute per caller IP; the request over the limit blocks that IP for 10 minutes | never retries a 429: the IP stays blocked for 10 minutes, so a retry only fails again |
| Member | Returns | Notes |
|---|---|---|
new ShieldLabsClient(ShieldLabsClientOptions, HttpClient?) |
client | ApiKey required; BaseUrl (default https://account.shieldlabs.ai), Timeout (10 s per attempt), MaxRetries (2) |
client.Identifications.GetAsync(requestId, GetIdentificationOptions?, CancellationToken) |
Task<Identification?> |
Wait (true), Timeout (10 s, total budget; the last lookup runs at the deadline), PollInterval (250 ms; the waits are 1, 2, 4, 6, then 8 times it, each at most 2 s, or at most PollInterval when that is longer) |
client.History.SearchAsync(LookupType, value, HistorySearchOptions?, CancellationToken) |
Task<HistoryPage> |
Limit 1-100 (20), Offset (0); HistoryPage.Data, HistoryPage.Total |
client.History.IterateAsync(LookupType, value, HistoryIterateOptions?, CancellationToken) |
IAsyncEnumerable<Identification> |
PageSize 1-100 (100), MaxItems; deduplicates on request ID |
new ManagementClient(ManagementClientOptions, HttpClient?) |
client | SecretKey, Domain required; BaseUrl (default https://api.shieldlabs.ai) |
management.GetProfileAsync(CancellationToken) |
Task<DomainProfile> |
Domain, RemainingIdentifications, PublicKeyMasked, SecretKeyMasked, CreatedAt, Raw |
WebhookSignature.Verify(payload, header, params secrets) |
bool |
payload as byte[] or string; never throws |
WebhookEvents.ConstructEvent(payload, header, params secrets) |
WebhookEvent |
throws SignatureVerificationException or WebhookParseException |
Risk.Band(int) |
RiskBand |
Trusted, Suspicious, Dangerous, RateLimited |
Risk.IsRateLimited(int) |
bool |
true above 100 |
Risk.Evaluate(Identification?, EvaluateOptions?) |
Evaluation |
Ok, Reason, Band, Flag; unknown BlockFlags names throw ValidationException |
UserHid.FromUserId(userId, secret) |
string |
64 lowercase hex characters |
Identification properties (JSON names follow the webhook contract): RequestId, VisitorId,
DeviceId, SessionId, CookieId, UserHid, Domain, PublicIp and LocalIp (Ip, Country),
ConnectionType (see ConnectionTypes), Os, Browser, DeviceType, TrafficSource (Channel,
ReferrerDomain, LandingUrl, ClickIdType, UtmSource, UtmMedium, UtmCampaign,
UtmContent, UtmTerm), RiskScore, Signals (Name, Weight, Description; known names in
SignalNames), DetectionFlags (19 booleans; wire names in DetectionFlagNames), ObservedAt
(UTC, millisecond precision, serialized as 2026-09-30T12:34:56.789Z), Source (History or
Webhook) and Raw (the original JSON object).
Every exception derives from ShieldLabsException:
| Exception | Cause | Retried |
|---|---|---|
ValidationException |
invalid argument or option; nothing was sent | no |
BadRequestException |
HTTP 400 | no |
AuthenticationException |
HTTP 401 or 403: wrong key, or wrong domain and Secret Key pair | no |
QuotaExceededException |
HTTP 402: no identifications left in the included volume | no |
NotFoundException |
HTTP 404, usually a wrong base URL | no |
RateLimitException |
HTTP 429; RetryAfter when the server sent it (zero for Retry-After: 0 or a date in the past) |
History API only |
ServerException |
HTTP 5xx | yes |
ApiException |
any other HTTP status; base class of the HTTP errors above, with StatusCode, Body, Error and Headers |
no |
ApiConnectionException |
the server could not be reached | yes |
ApiTimeoutException |
an attempt exceeded Timeout |
yes |
SignatureVerificationException |
the webhook signature is missing, malformed or does not match | n/a |
WebhookParseException |
a verified webhook body is not a valid event | n/a |
Retries apply to these GET requests only, with exponential backoff and jitter (0.5 s base, doubling,
at most 8 s). A Retry-After header is followed as sent, capped at 10 s: Retry-After: 0 or a date
in the past retries at once. A 429 without it is retried after the backoff, but never sooner than
1 s, because the History API counts requests per second. MaxRetries (default 2) sets how many times
a request is retried. While Identifications.GetAsync waits for a verdict,
each lookup is one attempt and the polling schedule does the retrying; there a 429 is followed by at
least 1 s whatever Retry-After says (see Wait for the verdict).
Cancelling the CancellationToken stops at once and throws OperationCanceledException. The SDK
never logs keys or bodies and sends no telemetry.
- .NET 8 and later use the
net8.0build; CI runs the tests on .NET 8, 9 and 10. - Runtimes that implement .NET Standard 2.0 (for example .NET Framework 4.7.2 and later) use the
netstandard2.0build, which depends onSystem.Text.JsonandMicrosoft.Bcl.AsyncInterfaces. CI runs the same tests against that build. await foreachoverIterateAsyncneeds C# 8 or later.- The SDK follows Semantic Versioning. Webhook events use
schema_version2026-06-01; other versions are accepted.
Documentation: docs.shieldlabs.ai. Support: contact@shieldlabs.ai.
The supported SDK reads responses through typed fields generated from the bundled OpenAPI description. History rows and totals, profile fields and headers, webhook data and search parameters are compiler-checked against that contract. Renaming or changing the type of a consumed field fails the build until the reader is updated. Generation also rejects changed operation routes, moved parameters and new required parameters until the supported transport is updated. New optional parameters are not sent by default.
These internal wire types retain the original JSON; the existing normalizer still handles unknown strings and fields, nulls and historical malformed values. Public models, retries, polling and signature verification are unchanged. No generated HTTP transport is used.
python3 -m pip install -r scripts/requirements.txt
./sync.sh # download the current OpenAPI description into resources/
./generate.sh # rebuild the SDK wire contract and the separate generated client
python3 scripts/generate-wire.py --check
python3 scripts/check-wire-drift.py # compile incompatible mutations, then an additive fieldsrc/ShieldLabs/Internal/WireContract.g.cs is the generated layer compiled into the supported
package. generated/ remains a separate stock client reference, not a package dependency: its
strict deserializers and .NET 8-only target do not preserve this SDK's tolerant parsing and
.NET Standard support. python3 scripts/generate-wire.py rebuilds only the supported wire
layer without Docker. Generation needs Python 3.10+ and the pinned PyYAML dependency.
Requirements: the .NET 8 SDK (or Docker).
dotnet build -c Release -warnaserror
dotnet test -c Release --no-build
dotnet pack src/ShieldLabs -c Release -o artifacts
python3 scripts/check-package.pyThe same with Docker:
docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:8.0 \
sh -c "dotnet build -c Release -warnaserror && dotnet test -c Release --no-build"tests/ShieldLabs.Tests/data holds the shared test fixtures that every ShieldLabs server SDK
passes. See CONTRIBUTING.md.
MIT. Copyright (c) 2026 ShieldLabs Inc.