# Introduction > Fingerly identifies the device behind every session and scores how suspicious it is, so your application can stop fraud without adding friction for real people. Fingerly is device intelligence for web and mobile applications. At the moments that matter, such as signing up, logging in or paying, it tells you **who** the visitor is and **how suspicious** the session looks, with every signal behind the answer. ## What you get from every identification | Answer | Field | Use it to | | --- | --- | --- | | A stable visitor ID | `visitor_id` | Recognise a returning device across sessions, logouts and cleared storage. | | How sure that is | `visitor_confidence` | Decide how much to trust the match, from 0 to 100. | | A suspect score and level | `suspect_score`, `suspect_level` | Allow, challenge or review, against a threshold you control. | | The signals behind it | `triggers` | See exactly why: Tor, a VPN, automation, a rooted phone, tampering and more. | | Network context | `country_code`, `asn`, `anonymity_network` | Understand where the session really comes from. | ## How an integration fits together 1. A **client SDK** in your web page or app identifies the visitor with a public key and returns a `request_id`. 2. Your frontend sends the `request_id` to your **backend** along with the action, such as the login form. 3. Your backend reads the stored result with a **secret key**, checks it belongs to this action, and decides. 4. **Webhooks** push every result to your systems as it happens, if you want them. The score never leaves your control. Fingerly does not block anyone on its own: it gives you evidence, and your code makes the decision. ## Start here - [Quick start](https://docs.fingerly.io/docs/quick-start): Identify your first visitor and verify it on your server. - [How Fingerly works](https://docs.fingerly.io/docs/how-it-works): From collected signals to a scored, stored event. - [Choose an SDK](https://docs.fingerly.io/docs/sdks): Web, mobile and server SDKs. - [API reference](https://docs.fingerly.io/reference/overview): Every endpoint, field and error. ## Principles - **Outcomes, explained.** Every score lists the signals and weights that produced it. - **Your policy.** Weights and thresholds are yours to tune, per organization or per key. - **No interruptions.** No SDK ever shows a permission prompt or a dialog to your visitors. - **Free to build.** Development and staging keys run the same detection as production and cost nothing. --- # Quick start > Create keys, identify a visitor in the browser, read the result with a secret key on your server, and act on it. The whole integration in five steps. This guide takes you from an empty account to a verified identification. It uses the browser SDK and Node.js; every step has the same shape in other languages. New accounts start with $3 of credit, and development keys are free. ### Step 1: Create an account and choose a region Sign up for the Fingerly dashboard. The owner of a new organization chooses where its visitor data will live, and the choice is permanent. See [regions](https://docs.fingerly.io/docs/regions). ### Step 2: Create a public key In **Integration > SDK keys**, create a **public** key in the **development** environment and add the origin your app runs on. A public key with no allowed origins refuses every request. ```text Allowed origin http://localhost:3000 ``` ### Step 3: Identify a visitor in the browser ```bash npm npm install @fingerly/web-js ``` ```bash pnpm pnpm add @fingerly/web-js ``` ```ts login.ts import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_development_…' }) form.addEventListener('submit', async (event) => { event.preventDefault() const { requestId } = await fingerly.identify({ tag: 'login' }) await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: form.email.value, password: form.password.value, requestId }), }) }) ``` > **Tip:** Using React, Vue, Next.js or a mobile app? The same step is in each [SDK's guide](https://docs.fingerly.io/docs/sdks). ### Step 4: Create a secret key and read the result on your server Create a **secret** key in the same environment and store it in your server's environment. The secret is shown once. When the login request reaches your backend, read the event its `requestId` names. ```ts Node.js import { load } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const event = await fingerly.events.get('01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4') ``` ```python Python import os from fingerly import Fingerly fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```python Python (async) import os from fingerly import AsyncFingerly fingerly = AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) event = await fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```go Go import fingerly "github.com/fingerly-io/fingerly-go" client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY")) event, err := client.Events.Get(ctx, "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```java Java import io.fingerly.server.FingerlyClient; FingerlyClient fingerly = FingerlyClient.builder() .secretKey(System.getenv("FINGERLY_SECRET_KEY")) .build(); Event event = fingerly.events().get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4"); ``` ```csharp .NET using Fingerly; var fingerly = new FingerlyClient(Environment.GetEnvironmentVariable("FINGERLY_SECRET_KEY")); var ev = await fingerly.Events.GetAsync("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4"); ``` ```php PHP events->get('01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4'); ``` ```ruby Ruby require "fingerly" fingerly = Fingerly::Client.new(secret_key: ENV.fetch("FINGERLY_SECRET_KEY")) event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```rust Rust let fingerly = fingerly::Client::new(std::env::var("FINGERLY_SECRET_KEY")?); let event = fingerly.events().get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4").await?; ``` ```bash cURL curl "https://us.api.fingerly.io/api/v1/events/01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4" \ -H "x-api-key: $FINGERLY_SECRET_KEY" ``` ### Step 5: Decide Check the event belongs to this action and is recent, then use the level to choose what happens. Start by recording what you would have done before enforcing it. ```ts Node.js import { load, FingerlyAPIError } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const MAX_AGE_MS = 2 * 60 * 1000 export async function decide(orderId: string, requestId: string) { let event try { event = await fingerly.events.get(requestId) } catch (error) { if (error instanceof FingerlyAPIError && error.status === 404) return 'refuse' throw error } if (event.tag !== 'checkout:' + orderId) return 'refuse' if (Date.now() - Date.parse(event.occurred_at) > MAX_AGE_MS) return 'refuse' if (event.suspect_level === 'high') return 'review' if (event.suspect_level === 'medium') return 'challenge' return 'allow' } ``` ```python Python from datetime import datetime, timedelta, timezone from fingerly import Fingerly, FingerlyAPIError fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) def decide(order_id: str, request_id: str) -> str: try: event = fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404: return "refuse" raise if event.tag != f"checkout:{order_id}": return "refuse" if datetime.now(timezone.utc) - event.occurred_at > timedelta(minutes=2): return "refuse" if event.suspect_level == "high": return "review" if event.suspect_level == "medium": return "challenge" return "allow" ``` ```python Python (async) from datetime import datetime, timedelta, timezone from fingerly import AsyncFingerly, FingerlyAPIError fingerly = AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) async def decide(order_id: str, request_id: str) -> str: try: event = await fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404: return "refuse" raise if event.tag != f"checkout:{order_id}": return "refuse" if datetime.now(timezone.utc) - event.occurred_at > timedelta(minutes=2): return "refuse" return {"high": "review", "medium": "challenge"}.get(event.suspect_level, "allow") ``` ```go Go func decide(ctx context.Context, orderID, requestID string) (string, error) { event, err := client.Events.Get(ctx, requestID) var apiErr *fingerly.APIError if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound { return "refuse", nil } else if err != nil { return "", err } if event.Tag != "checkout:"+orderID || time.Since(event.OccurredAt) > 2*time.Minute { return "refuse", nil } switch event.SuspectLevel { case "high": return "review", nil case "medium": return "challenge", nil } return "allow", nil } ``` ```java Java public String decide(String orderId, String requestId) { Event event; try { event = fingerly.events().get(requestId); } catch (FingerlyApiException e) { if (e.getStatus() == 404) return "refuse"; throw e; } if (!("checkout:" + orderId).equals(event.getTag())) return "refuse"; if (event.getOccurredAt().isBefore(Instant.now().minus(Duration.ofMinutes(2)))) return "refuse"; return switch (String.valueOf(event.getSuspectLevel())) { case "high" -> "review"; case "medium" -> "challenge"; default -> "allow"; }; } ``` ```csharp .NET public async Task 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", }; } ``` ```php PHP events->get($requestId); } catch (ApiException $e) { if ($e->getStatus() === 404) { return 'refuse'; } throw $e; } if ($event->tag !== "checkout:{$orderId}") { return 'refuse'; } if ($event->occurredAt < new DateTimeImmutable('-2 minutes')) { return 'refuse'; } return match ($event->suspectLevel) { 'high' => 'review', 'medium' => 'challenge', default => 'allow', }; } ``` ```ruby Ruby def decide(order_id, request_id) event = fingerly.events.get(request_id) return "refuse" unless event.tag == "checkout:#{order_id}" return "refuse" if event.occurred_at < Time.now - 120 case event.suspect_level when "high" then "review" when "medium" then "challenge" else "allow" end rescue Fingerly::APIError => e raise unless e.status == 404 "refuse" end ``` ```rust Rust async fn decide(fingerly: &fingerly::Client, order_id: &str, request_id: &str) -> Result { let event = match fingerly.events().get(request_id).await { Ok(event) => event, Err(fingerly::Error::Api { status: 404, .. }) => return Ok(Decision::Refuse), Err(error) => return Err(error), }; if event.tag.as_deref() != Some(&format!("checkout:{order_id}")) { return Ok(Decision::Refuse); } if chrono::Utc::now() - event.occurred_at > chrono::Duration::minutes(2) { return Ok(Decision::Refuse); } Ok(match event.suspect_level { Some(Level::High) => Decision::Review, Some(Level::Medium) => Decision::Challenge, _ => Decision::Allow, }) } ``` ## Try it Open your page, log in, then open **Identification > Events** in the dashboard. Your identification is there with its visitor, score and signals. Now try a private window, a VPN or an automated browser, and watch the score change. ## Go live - Create **production** public and secret keys with your real origins, and deploy them. - Production identifications cost $0.003 each. Add funds or turn on auto top-up in **Settings > Billing**. - Review your [risk weights](https://docs.fingerly.io/docs/risk-weights) and threshold against staging traffic before you enforce decisions. - Add [webhooks](https://docs.fingerly.io/docs/webhooks) if you want results pushed to you. - [Server-side verification](https://docs.fingerly.io/docs/server-side-verification): The checks to make before you trust a result. - [Plan your integration](https://docs.fingerly.io/docs/planning-your-integration): Where to identify, what to tag, what to decide. --- # How Fingerly works > Follow one identification from the SDK collecting signals, through identification and scoring on the server, to the stored event, the webhook and your decision. One identification takes a few hundred milliseconds end to end. Here is what happens in that time. ## 1. The SDK collects When you call `identify()`, the SDK reads device and browser characteristics within a strict time budget: 300 ms for the initial tier in browsers. Every source is isolated, so one that is slow, blocked or broken is recorded as such and never holds up the answer. Nothing is ever asked of the visitor. The SDK also forms local verdicts, such as automation or a rooted device, which your interface can use right away. ## 2. The server identifies The report is sent to your region's API with your public key. Fingerly resolves it to a `visitor_id` scoped to your organization: the same device gets the same ID on its next visit, even after cookies and storage are cleared, and a device seen for the first time gets a new one. `visitor_confidence` says how close the match was. ## 3. The server enriches and scores The visitor's address is looked up for its country, network and whether it belongs to Tor, a VPN, a proxy or a hosting provider. The server then runs its own detection over the report and the network context, independently of the SDK's local verdicts, and adds up the weights of the signals that fired into the `suspect_score`. The score is compared with your threshold to give a level: `low`, `medium` or `high`. Weights and the threshold come from your organization's profile, or a key's own profile when it has one. ## 4. The answer comes back The same response returns the request ID, the visitor, the confidence, the score, the level and the triggers. There is no second call to make and nothing to poll. ## 5. The event is stored and sent - The event becomes readable with your secret key within seconds, and stays readable for 30 days. - Subscribed webhook endpoints receive `identification.completed`, and `visitor.suspect` when the level is `high`. - The event appears in the dashboard's Events view and analytics. ## 6. The browser keeps collecting Slower signals continue in the browser for up to 1,200 ms after the answer and are attached to the same request as a deferred report. They are archived with the event but never change its identity, score, cost or webhooks. ## 7. You decide Your frontend sends the request ID to your backend with the action. Your backend reads the event with its secret key and makes the decision. Because the event is read from Fingerly, not from the browser, it cannot be edited on the way. > **Note:** Every answer is evidence, not a verdict on a person. Fingerly never blocks traffic on its own behalf. --- # Plan your integration > Decide where to identify, how to tag actions, which keys each environment needs, and how to roll out a risk policy without surprising real customers. A good integration is a few decisions made up front. This page walks through them. ## Where to identify Identify at the moments that carry risk or value, not on every page view. Each production identification is billed. | Moment | Tag | Typical decision | | --- | --- | --- | | Sign-up | `signup` | Limit accounts per visitor; review high-risk sign-ups. | | Login | `login` | Step up to a second factor on medium; lock on high. | | Checkout | `checkout:` | Hold high-risk orders for review. | | Promotion redemption | `promo:` | One redemption per visitor. | | Password reset | `password-reset` | Slow down resets from new, high-risk visitors. | > **Tip:** The framework SDKs share one identification per page, so a component tree that asks several times still makes one request. ## Tag every identification A tag binds an identification to the action it was made for. When your server checks the tag, a request ID captured on a harmless page cannot be replayed at checkout. Include an identifier from the action itself, such as an order ID, where you have one. ## Keys per environment | Environment | Keys | Billed | Use for | | --- | --- | --- | --- | | Development | Public and secret | No | Laptops and CI. | | Staging | Public and secret | No | Pre-release testing with realistic traffic. | | Production | Public and secret, optionally a proxy key | Yes | Real visitors. | Each environment's events, dashboards and webhooks are kept apart, so test traffic never shows up in production numbers. See [API keys and environments](https://docs.fingerly.io/docs/api-keys). ## Roll out a policy ### Step 1: Observe Identify and store results without acting on them. Log what each level would have done. ### Step 2: Tune Review high and medium sessions in the dashboard. Adjust [risk weights](https://docs.fingerly.io/docs/risk-weights) and the threshold until the levels match what you see. ### Step 3: Add friction first Act on `medium` with friction a real customer can pass, such as a second factor. ### Step 4: Enforce Act on `high` once you trust it: review, block, or limit. > **Warning:** Never make an identification failure fatal for the visitor. If the SDK cannot reach Fingerly, let your server treat the missing request ID as missing evidence. ## Decide on the server Always read results with a secret key on your backend. Anything the browser reports, including the score, can be changed by whoever controls the browser. See [server-side verification](https://docs.fingerly.io/docs/server-side-verification). ## Consent The SDKs collect as soon as they are called, unless you load them with a `consent` state other than `granted`. If your legal basis requires consent, load with `pending` and pass on your consent tool's answer. See [consent tools](https://docs.fingerly.io/docs/consent-tools). ### Checklist - Identification points chosen and tagged. - Public key origins list every production domain. - Secret key stored only on servers. - Server checks the tag, the age and the level. - Failures degrade to missing evidence, not errors. - Consent gate in place where required. --- # Migrate from FingerprintJS Pro > Move a FingerprintJS Pro integration to Fingerly: map get() to identify(), Smart Signals to signal groups, and their webhooks to Fingerly webhooks, without downtime. The two products share a shape: a client agent identifies the visitor and returns a request ID, and your server reads the result with a secret key. Most of a migration is renaming. This guide maps each part, then walks through switching over without a gap in protection. > **Note:** FingerprintJS Pro (now Fingerprint) names below are those of its JavaScript agent v3, its React library and its Server API v3. If you use a newer version, the names may differ; the concepts map the same way. Fingerly is not affiliated with Fingerprint. ## At a glance | Concept | FingerprintJS Pro | Fingerly | | --- | --- | --- | | Load the agent | `FingerprintJS.load({ apiKey })` | `load({ apiKey })` | | Identify | `fp.get()` | `fingerly.identify()` | | Your reference | `linkedId` and `tag` | One `tag` string | | Visitor | `visitorId` | `visitorId`, scoped to your organization | | Match confidence | `confidence.score`, 0 to 1 | `visitorConfidence`, 0 to 100 | | Risk | Smart Signals, Suspect Score | Signal groups, `suspect_score` and `suspect_level` | | Read one result | Server API `GET /events/{request_id}` | [`GET /api/v1/events/{request_id}`](https://docs.fingerly.io/reference/get-event) | | Visitor history | Server API `GET /visitors/{visitor_id}` | [`GET /api/v1/events?visitor=…`](https://docs.fingerly.io/reference/list-events), 30 days | | Webhooks | One per identification, with Smart Signals | [`identification.completed`](https://docs.fingerly.io/reference/webhooks/identification-completed), and [`visitor.suspect`](https://docs.fingerly.io/reference/webhooks/visitor-suspect) with signals | | Region | `region` load option | Part of the key | | First-party requests | Custom subdomain or cloud proxy integrations | [Proxy integrations](https://docs.fingerly.io/docs/proxy-integrations) | ## Plan the switch ### Step 1: Create Fingerly keys Create a public key with your site's origins and a secret key, first in **development**, then in **production**. Unlike FingerprintJS Pro's optional request filtering, allowed origins are required: a public key with none refuses every browser request. See [API keys](https://docs.fingerly.io/docs/api-keys). ### Step 2: Run both agents side by side Call Fingerly next to FingerprintJS Pro at the same moments and send both request IDs to your server. Keep deciding with FingerprintJS Pro, and log what Fingerly would have decided. ### Step 3: Move server checks When the two agree on the sessions you care about, switch your decisions to the Fingerly event. Keep reading FingerprintJS Pro results for a few days as a fallback. ### Step 4: Move webhooks Register Fingerly endpoints, verify their signatures, and point your consumers at them. ### Step 5: Remove the old agent Delete the FingerprintJS Pro agent, its proxy routes and its keys. > **Warning:** Visitor IDs do not carry over. Fingerly issues its own, so a device you knew under a FingerprintJS Pro visitor ID starts as a new visitor. Store both IDs while you run side by side, and expect more "new device" decisions for a while. See [identities do not carry over](#identities-do-not-carry-over). ## Replace the JavaScript agent ```ts FingerprintJS Pro import * as FingerprintJS from '@fingerprintjs/fingerprintjs-pro' const fp = await FingerprintJS.load({ apiKey: '', region: 'eu', endpoint: ['https://metrics.example.com', FingerprintJS.defaultEndpoint], }) const { requestId, visitorId } = await fp.get({ linkedId: user.id, tag: { action: 'login' } }) ``` ```ts Fingerly import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…', endpoints: '/metrics', fallbackToDefaultEndpoint: true, }) const { requestId, visitorId } = await fingerly.identify({ tag: 'login' }) ``` ### Load options | FingerprintJS Pro | Fingerly | Notes | | --- | --- | --- | | `apiKey` | `apiKey` | A Fingerly public key, `fly_pk_…`. | | `region` | None | The region is part of the key, and the SDK routes by it. | | `endpoint` | `endpoints` | Paths or URLs for a [proxy](https://docs.fingerly.io/docs/proxy-integrations). `/api/v1/identify` is appended. | | `FingerprintJS.defaultEndpoint` in the list | `fallbackToDefaultEndpoint: true` | Try the regional API after your own endpoints fail. | | `scriptUrlPattern` | None | The SDK is bundled into your application. Nothing is downloaded at runtime, so there is no script to proxy. | ### get() options and result | FingerprintJS Pro | Fingerly | Notes | | --- | --- | --- | | `linkedId` | Part of `tag` | Fingerly has one reference field. Put the account or order in it, such as `login` or `checkout:8412`. | | `tag` (any JSON) | `tag` (a string) | Encode what you need as a string. Your server compares it before trusting the result. | | `extendedResult` | None | Network and device details are on the stored event your server reads. | | `timeout` | `signal` | Pass an `AbortSignal`. Collection is already capped at 300 ms, and each attempt at 5 seconds. | | `requestId` | `requestId` | Send it to your server with the action. | | `visitorId` | `visitorId` | Twenty letters and digits, scoped to your organization. | | `confidence.score` | `visitorConfidence` | 0 to 100 rather than 0 to 1. See [visitor confidence](https://docs.fingerly.io/docs/visitor-identification#visitor-confidence). | | `visitorFound` | `visitorIsNew` | The opposite sense. | | `sealedResult` | None | Read the event on your server with a secret key instead. | Fingerly also returns the score, the level and the signals in the same response, and local [verdicts](https://docs.fingerly.io/docs/client-verdicts) computed in the browser. Use them to adapt the interface; decide on your server. ## Replace the React library ```tsx FingerprintJS Pro import { FpjsProvider, useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react' ' }}> const { getData, isLoading } = useVisitorData({ extendedResult: true }, { immediate: false }) const { requestId } = await getData({ ignoreCache: true }) ``` ```tsx Fingerly import { FingerlyProvider, useIdentify } from '@fingerly/react' const { identify, isLoading } = useIdentify({ tag: 'login' }) const { requestId } = await identify({ force: true }) ``` `useIdentify({ immediate: true })` replaces `immediate: true`, and `refresh()` replaces `getData({ ignoreCache: true })`. Vue, Svelte, Angular and Next.js have the same shape: see [the SDKs](https://docs.fingerly.io/docs/sdks). ### Mobile On iOS and Android, replace the FingerprintJS Pro client and its visitor ID calls with `Fingerly.load(apiKey:)` and `identify(tag:)`. The result has the same fields as on the web. See [iOS](https://docs.fingerly.io/docs/sdks/ios) and [Android](https://docs.fingerly.io/docs/sdks/android), or [React Native](https://docs.fingerly.io/docs/sdks/react-native) and [Flutter](https://docs.fingerly.io/docs/sdks/flutter). ## Replace server reads ```ts FingerprintJS Pro import { FingerprintJsServerApiClient, Region } from '@fingerprintjs/fingerprintjs-pro-server-api' const client = new FingerprintJsServerApiClient({ apiKey: process.env.FP_SECRET_API_KEY!, region: Region.EU }) const event = await client.getEvent(requestId) const { visitorId, linkedId, timestamp } = event.products.identification.data const isBot = event.products.botd?.data?.bot.result === 'bad' const usesVpn = event.products.vpn?.data?.result === true ``` ```ts Fingerly import { load } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const fired = (event: { triggers: Array<{ signal: string }> }, group: string) => event.triggers.some((trigger) => trigger.signal === group) const event = await fingerly.events.get(requestId) const { visitor_id, tag, occurred_at } = event const isBot = fired(event, 'bot') const usesVpn = fired(event, 'vpn') ``` | FingerprintJS Pro | Fingerly event | | --- | --- | | `products.identification.data.visitorId` | `visitor_id` | | `products.identification.data.requestId` | `request_id` | | `products.identification.data.linkedId`, `tag` | `tag` | | `products.identification.data.timestamp` (milliseconds) | `occurred_at` (RFC 3339) | | `products.identification.data.ip` | `ip_address` | | `ipLocation` country | `country_code`, `country_name` | | `products.ipInfo` network | `asn`, `asn_name`, `anonymity_network` | | `browserDetails` | `agent`, `agent_version`, `os`, `device_kind` | | `products.suspectScore.data.result` | `suspect_score`, with `suspect_level` from your threshold | | Each Smart Signal's `result` | A group in `triggers`, with its `weight` and `confidence` | An event lists only the signal groups that fired. A group that is absent did not fire. Server libraries for [Python, Go, Java, .NET, PHP, Ruby and Rust](https://docs.fingerly.io/docs/sdks#server) make the same calls. ## Smart Signals and signal groups | Smart Signal | Fingerly group | Notes | | --- | --- | --- | | Bot detection, `bad` | `bot` | Automation, and clients that say they are bots. | | Bot detection, `good` | None | Read `visitor_kind`: `search_crawler` or `ai_bot`. | | VPN detection | `vpn` | Weighed by how it was recognised or by confidence. See [weighting modes](https://docs.fingerly.io/docs/risk-weights#weighting-modes). | | Tor | `tor` | | | Proxy detection | `datacenter_proxy`, `residential_proxy` | Two groups, weighted separately. | | IP blocklist | `ip_blocklist` | | | Incognito mode | `incognito_mode` | | | Browser tampering | `browser_tampering` | | | Virtual machine | `virtual_machine` | | | Privacy-focused settings | `privacy_settings` | Also `fingerprint_suppressed` when too little can be read to identify anyone. | | Developer tools | `developer_tools` | | | High-activity device | `high_activity` | | | Geolocation spoofing | `location_spoofing` | | | Remote control tools | `remote_control` | Browsers only. | | Root apps (Android) | `rooted_device` | | | Emulator (Android) | `android_emulator` | The iOS Simulator is `ios_simulator`. | | Cloned app (Android) | `cloned_app` | | | Jailbroken device (iOS) | `jailbroken_device` | | | Frida detection | `frida_detected` | | | MITM attack | `mitm_attack` | | | Factory reset | `device_farm` | A signal that the device looks freshly reset or mass-provisioned, not a reset timestamp. | | Velocity signals | None | Count per `visitor_id` in your own store. `high_activity` covers devices seen far more often than usual. | | Raw device attributes | None | Fingerly does not expose what it collects from a device. | Every group has a weight you can change, per organization or per key, and the score is their sum. See [signals](https://docs.fingerly.io/docs/signals) and [risk weights](https://docs.fingerly.io/docs/risk-weights). ## Replace webhooks | | FingerprintJS Pro | Fingerly | | --- | --- | --- | | What is sent | One request per identification, with Smart Signals | `identification.completed` per identification; `visitor.suspect` with every signal when the level is `high` | | Signature header | `FPJS-Event-Signature: v1=…` | `X-Fingerly-Signature: sha256=…`, with `X-Fingerly-Timestamp` | | Signed content | The body | The timestamp, a full stop, and the body | | Replay protection | None in the signature | Reject timestamps more than five minutes old | | Deduplication | By `requestId` | By the envelope `id`, also in `X-Fingerly-Event-ID` | ```ts FingerprintJS Pro import { isValidWebhookSignature } from '@fingerprintjs/fingerprintjs-pro-server-api' const valid = isValidWebhookSignature({ header: req.get('fpjs-event-signature'), data: rawBody, secret: process.env.FP_WEBHOOK_SECRET!, }) const visit = JSON.parse(rawBody.toString()) ``` ```ts Fingerly import { verifyWebhook } from '@fingerly/node' const valid = verifyWebhook({ secret: process.env.FINGERLY_WEBHOOK_SECRET!, payload: rawBody, timestamp: req.get('x-fingerly-timestamp'), signature: req.get('x-fingerly-signature'), }) const { type, data } = JSON.parse(rawBody.toString()) ``` If your consumer needs every signal for every identification, read the event by `data.request_id` when `identification.completed` arrives. See [webhooks](https://docs.fingerly.io/docs/webhooks). ## Proxy integrations FingerprintJS Pro's proxy integrations forward both the agent download and its requests. Fingerly's SDK is part of your bundle, so a Fingerly proxy only forwards two API routes. Use the [Cloudflare Worker](https://docs.fingerly.io/docs/sdks/cloudflare-worker), [`createProxy`](https://docs.fingerly.io/docs/sdks/node#serve-the-browser-sdk-from-your-domain) for Node.js runtimes, or [your own proxy](https://docs.fingerly.io/docs/proxy-integrations#build-your-own-proxy). Choose a new path rather than reusing the old one, so both can run during the switch. ## Identities do not carry over - Store the Fingerly `visitor_id` next to the FingerprintJS Pro one on your accounts, orders and devices while both run. - Rules such as "known device for this account" start empty. Loosen new-device friction during the switch, or keep consulting the old IDs until customers have returned once. - Counters per visitor, such as sign-ups per device, restart from zero. Keep old limits running on the old IDs until the window they cover has passed. ## Other differences - **Environments.** Keys belong to development, staging or production. Development and staging run the same detection and are free. See [environments](https://docs.fingerly.io/docs/api-keys#environments). - **Pricing.** Prepaid, $0.003 per production identification, with no plans or minimums. See [billing](https://docs.fingerly.io/docs/billing). - **Retention.** Events are readable for 30 days. Keep your own copy for longer. See [data retention](https://docs.fingerly.io/docs/data-retention). - **Regions.** The United States region is available; the European Union region is coming soon. See [regions](https://docs.fingerly.io/docs/regions). - **Consent.** Every SDK takes a `consent` option and collects nothing until it is `granted`. See [consent tools](https://docs.fingerly.io/docs/consent-tools). > **Tip:** Stuck on something this page does not map? Email [support@fingerly.io](mailto:support@fingerly.io) with the FingerprintJS Pro feature you use. - [Quick start](https://docs.fingerly.io/docs/quick-start): The Fingerly flow end to end. - [Use cases](https://docs.fingerly.io/docs/use-cases): Recipes for the policies you are moving. --- # Visitor identification > What a Fingerly visitor ID is, how stable it is, what visitor confidence means, and how to use them to recognise returning devices. Every identification returns a `visitor_id`: a stable identifier for the device, resolved by the server. The same device returns the same visitor ID on its next visit, whether or not it kept its cookies. ## The visitor ID - Twenty letters and digits, such as `X9pL2mRc7KvT4bQw8NdF`. Treat it as an opaque string. - Issued by Fingerly and never derived from the device's data, so it reveals nothing about the device. - **Scoped to your organization.** The same device on another customer's site has a different visitor ID. Visitor IDs cannot be used to follow people across companies. - The same across your development, staging and production keys. - Always present on a successful identification. The ID is designed to survive the changes devices go through: browser and operating system updates, new fonts, a different monitor, travel between networks. ## Visitor confidence `visitor_confidence` says how the ID was reached, from 0 to 100. | Value | Meaning | How to treat it | | --- | --- | --- | | `100` | This exact device has been seen before. | A confident returning visitor. | | `85` to `99` | Recognised after the device changed, such as after an update. | A returning visitor. Lower values mean more change. | | `0` | Nothing matched, so a new visitor ID was issued. | A new visitor, or one Fingerly could not recognise. | ## New visitors `visitor_is_new` is `true` the first time your organization sees a visitor. Use it for new-account and first-purchase decisions, such as whether a welcome offer should apply. ## When a device cannot be identified Some browsers and clients give away almost nothing, whether through hardened privacy settings or deliberately. When there is too little to identify anyone, the response has `identifiable: false`: - A fresh visitor ID is issued each time, so these clients appear as a stream of new visitors. Do not count them as returning or unique. - The request is still scored, and the `fingerprint_suppressed` signal is added. - A production request that is not identifiable costs $0.0005 instead of $0.003. ## Using visitor IDs | Pattern | Example | | --- | --- | | Limit accounts per device | Refuse a fifth sign-up from one visitor ID in a day. | | Recognise a returning customer | Skip a second factor for a known visitor with confidence `100` on their usual account. | | Link accounts that share a device | Store the visitor ID with each account, and review accounts that share one. | | Enforce one-per-customer offers | Allow one promotion redemption per visitor ID. | > **Tip:** Store `visitor_id` alongside your own user, order or session records. Most of the value comes from joining it with what you already know. > **Note:** A visitor ID identifies a device, not a person. Several people can share a device, and one person can use several. --- # Suspect score > How the suspect score is calculated from weighted signals, how levels are derived from your threshold, and why an unscored request is not a zero. The suspect score summarises how suspicious a session looks. It is simple on purpose: a sum of weights you can read, explain and change. ## How the score is calculated Each signal that fires adds its weight once. The score is the sum. ```text Example tor 14 automation 9 tampering 8 high_activity 6 ---------------------- suspect_score 37 → high (threshold 30) ``` - The score is not a percentage and has no maximum. - Every signal that fired is listed in `triggers` with its weight and confidence, heaviest first. A signal with weight `0` still appears. - Weights come from your [risk weights](https://docs.fingerly.io/docs/risk-weights): a key's own profile if it has one, otherwise your organization's, otherwise the defaults. ## Levels The level compares the score with your threshold, which is `30` by default. | Level | When | With the default threshold | | --- | --- | --- | | `low` | The score is below half the threshold. | 0 to 14 | | `medium` | The score is at least half the threshold. | 15 to 29 | | `high` | The score is at least the threshold. | 30 and above | An identification that reaches `high` also sends the [`visitor.suspect`](https://docs.fingerly.io/reference/webhooks/visitor-suspect) webhook. ## When a request is not scored If the network lookup could not run, the request is answered but not scored: `state` is `unavailable`, and `suspect_score` and `suspect_level` are absent. Fingerly does not invent a score from half the evidence. > **Warning:** An absent score is not a score of `0`. `0` means everything was checked and nothing fired. Absent means the check did not happen. Decide separately what your application does in that case. ```ts Handling an unscored request if (event.suspect_score === null) { // Not scored: fall back to your own rules, or ask for a second factor. } else if (event.suspect_level === 'high') { // … } ``` Requests whose network lookup could not run are not billed. ## Confidence Each trigger has a `confidence` of `low`, `medium` or `high`, which says how strong the evidence for that signal was. Confidence is reported, not multiplied into the weight: a high-confidence signal with weight `0` adds nothing. Use confidence when you write rules on individual triggers. ## Choosing actions | Level | A common first policy | | --- | --- | | `low` | Allow. | | `medium` | Add friction a real customer can pass, such as a second factor or a CAPTCHA. | | `high` | Hold for review, limit what the session can do, or refuse. | - [Signals reference](https://docs.fingerly.io/docs/signals): Every signal group and its default weight. - [Risk weights](https://docs.fingerly.io/docs/risk-weights): Change what each signal is worth. --- # Signals reference > Every signal group Fingerly scores on web, Android and iOS: what each means, its key in API responses and webhooks, and its default weight. Signals are grouped by what they tell you. Events read with a secret key list triggers by group; the identify response and the `visitor.suspect` webhook name the individual signal and its group. The dashboard's **Smart Signals > Signal reference** lists every individual signal. ## Signal groups | Group | Key | Meaning | Web | Android | iOS | | --- | --- | --- | --- | --- | --- | | Tor exit node | `tor` | The connection arrives from the Tor network. | 14 | 16 | 17 | | Datacenter proxy | `datacenter_proxy` | The address belongs to a proxy or a hosting provider rather than a consumer connection. | 14 | 12 | 15 | | Residential proxy | `residential_proxy` | Traffic is relayed through someone else's home connection. | 6 | 6 | 6 | | VPN | `vpn` | The connection arrives over a VPN. | 4 | 6 | 6 | | IP reputation | `ip_reputation` | The address has a recent history of abuse. | 8 | 8 | 8 | | IP blocklist | `ip_blocklist` | The address is on a public blocklist for sending spam or for attacks. | 14 | 13 | 13 | | Location spoofing | `location_spoofing` | The device's own location settings disagree with where its connection is. | 5 | 5 | 5 | | Network anomaly | `network_anomaly` | The address should not appear on the public internet. Off by default. | 0 | 0 | 0 | | Bot | `bot` | The client is automated, or says it is a bot. | 9 | n/a | n/a | | Virtual machine | `virtual_machine` | The browser runs inside a virtual machine. | 14 | n/a | n/a | | Remote control | `remote_control` | The session is driven through remote desktop software, as in many support scams. | 14 | n/a | n/a | | Browser tampering | `browser_tampering` | The runtime has been modified, or claims to be something it is not. | 8 | 8 | 8 | | Fingerprint suppressed | `fingerprint_suppressed` | Too little was collected to identify the device. | 16 | 16 | 16 | | Incognito mode | `incognito_mode` | The page is in a private browsing window. | 4 | n/a | n/a | | Privacy settings | `privacy_settings` | The browser runs hardened privacy settings. | 6 | n/a | n/a | | High activity | `high_activity` | The device has been seen far more often than an ordinary one. | 6 | 5 | 6 | | Device farm | `device_farm` | The device looks mass-provisioned or freshly reset, or shares traits with many devices at once. | 7 | 8 | 8 | | Developer tools | `developer_tools` | A debugger or developer tooling is attached. | n/a | 8 | 16 | | Rooted device | `rooted_device` | The Android device is rooted. | n/a | 12 | n/a | | Android emulator | `android_emulator` | The app runs in an Android emulator. | n/a | 9 | n/a | | Cloned app | `cloned_app` | The app runs inside a cloning framework, or is not your signed build. | n/a | 9 | n/a | | Jailbroken device | `jailbroken_device` | The iOS device is jailbroken. | n/a | n/a | 10 | | iOS simulator | `ios_simulator` | The app runs in the iOS Simulator. | n/a | n/a | 16 | | Instrumentation | `frida_detected` | An instrumentation toolkit is attached to the app. | n/a | 14 | 14 | | MITM attack | `mitm_attack` | Something is intercepting the app's encrypted traffic. | n/a | 14 | 14 | | Active call | `active_call` | The device is on a phone call during the action, a common pattern in scams. | n/a | 3 | 3 | Weights are the defaults for each platform: a group's weight is its heaviest signal's. `n/a` means the group does not apply on that platform, which is different from a weight of `0`. ## Network signals Network signals come from the visitor's IP address and apply to every platform. The same lookup fills in `country_code`, `asn`, `asn_name` and `anonymity_network` on the event. VPN and residential proxy findings carry a confidence from how many independent indications agree. You can weigh them by how they were recognised, or by that confidence. See [weighting modes](https://docs.fingerly.io/docs/risk-weights#weighting-modes). ## Device and runtime signals Device signals come from the SDK's report and are checked again on the server. On iOS and Android, the native SDKs reach what JavaScript cannot, which is why mobile has signals such as rooted and jailbroken devices, emulators and instrumentation. ## Behaviour signals `high_activity` and the cross-device part of `device_farm` compare a device with your own traffic over the last 5 minutes, hour and day. They learn what normal looks like for each environment and platform first: until there are at least 1,000 identifications and 7 days of history, they are reported but add nothing to the score. ## Signal names are permanent A signal's name never changes meaning. If what a detection measures changes, it becomes a new signal with a new name, so rules you write against today's names stay correct. > **Tip:** Start with the defaults. They are hand-set to reflect how strongly each signal indicates fraud, not trained on your traffic, so tune them once you have your own data. See [risk weights](https://docs.fingerly.io/docs/risk-weights). --- # Risk weights > Change what each signal is worth and where the high level starts, for your whole organization or for a single key, without redeploying anything. Risk weights decide how much each signal adds to the [suspect score](https://docs.fingerly.io/docs/suspect-score), and the threshold decides where `high` begins. Changes apply to the next identification: no SDK update, no redeploy. ## Profiles Weights live in profiles, one per platform: web, Android and iOS. When an identification is scored, Fingerly uses the first profile that exists: | Order | Profile | Set in | | --- | --- | --- | | 1 | The SDK key's own profile, when it has one | The [management API](https://docs.fingerly.io/reference/management/risk-weights#save-an-sdk-key-s-weights) | | 2 | Your organization's profile | The dashboard or the [management API](https://docs.fingerly.io/reference/management/risk-weights) | | 3 | The defaults | Built in | A profile can be partial. A signal it does not mention uses the default weight, while a signal set to `0` stays at `0`. The event records which profile scored it. > **Tip:** Your organization's profile applies to every environment at once. To try a new policy on staging first, give your staging keys their own profile with the [management API](https://docs.fingerly.io/reference/management/risk-weights#save-an-sdk-key-s-weights), or [replay recent events](https://docs.fingerly.io/docs/testing#test-a-policy-change) against the change. ## Change weights in the dashboard Open **Smart Signals > Suspect Score**. Each signal has a weight per platform, next to the threshold and the weighting modes. Changes save as you make them. Owners and admins can edit; every member can view. ## Limits - Weights are whole numbers from `0` to `10000`. - The threshold is a whole number from `1` to `1000000`. The default is `30`. - A signal that does not exist on a platform cannot be given a weight there. ## Weighting modes VPN and residential proxy findings can be weighed in one of two ways, chosen per profile. | Mode | Weighs | Choose it when | | --- | --- | --- | | `method` (default) | Each way the VPN or proxy was recognised, separately. | You want to treat, say, a mobile carrier VPN differently from a commercial one. | | `confidence` | One weight each for low, medium and high confidence. | You care how sure the finding is, not how it was made. | ## Safe changes Profiles carry a revision. When two people edit at once, the second save is refused with a conflict rather than silently overwriting the first. Reload and apply your change again. ## A worked example ```text Before and after automation 9 + tampering 8 + high_activity 6 + privacy_settings 6 = 29 medium automation 12 + tampering 8 + high_activity 6 + privacy_settings 6 = 32 high ``` Raising one weight moved the same session across the threshold. Look at real sessions near your threshold in **Insights > Suspect score trends** before and after a change. > **Warning:** Lowering the threshold raises how many visitors reach `high`, and how many `visitor.suspect` webhooks you receive. [Replay recent events](https://docs.fingerly.io/docs/testing#test-a-policy-change) against the new threshold first. --- # Client-side verdicts > The verdicts each SDK computes on the device, what they are good for, and why decisions should still be made on your server. Every client SDK computes verdicts on the device, before and independently of the server. They arrive with the identify result as `verdicts`. ```ts A verdict result.verdicts.automation // { value: true, confidence: 'high', reasons: [ … ] } ``` **Verdict** - `value` (boolean): Whether the verdict matched. - `confidence` (string): `low`, `medium` or `high`. - `reasons` (string[]): Short, stable tokens for the evidence, strongest first. Useful for logging. ## Verdicts by platform | Platform | Verdicts | | --- | --- | | Web | `incognito`, `shields`, `tor`, `emulator`, `automation`, `farm` | | iOS | `jailbreak`, `simulator`, `instrumentation`, `mitm`, `automation`, `tampering`, `farm` | | Android | `root`, `emulator`, `appCloner`, `instrumentation`, `mitm`, `automation`, `tampering`, `farm` | `shields` means anti-fingerprinting protections are active. It describes a privacy choice, and on its own is never a reason to challenge anyone. ## What they are for - Adapting the interface immediately, such as showing a second-factor step before the form is submitted. - Collecting without sending, with `submit: false`, to test or to debug. - Logging what the device looked like alongside your own events. ## What they are not for > **Warning:** Do not make security decisions on local verdicts. Code running on the visitor's device can be changed by the visitor. The server runs its own detection over the report and network context, with your weights, and its score is the answer to act on. ```ts Frontend: adapt the interface const result = await fingerly.identify({ tag: 'login' }) if (result.verdicts.automation.value) showSecondFactorStep() ``` ```ts Backend: decide async function decide(requestId: string) { const event = await fingerly.events.get(requestId) if (event.suspect_level === 'high') return refuse() return allow() } ``` ## In framework SDKs The framework SDKs read one verdict with a confidence floor through `useVerdict`, `injectVerdict` and their equivalents, and offer `isSuspicious`, which is `true` when any verdict matched at `medium` or above. > **Note:** In browsers, the verdicts on `identify()`'s result come from the initial collection tier. Verdicts over both tiers arrive with `result.deferred`. ### Related - [Server-side verification](https://docs.fingerly.io/docs/server-side-verification): Read the stored result with a secret key. - [Signals reference](https://docs.fingerly.io/docs/signals): What the server scores. --- # Server-side verification > Never trust a result the browser reports. Read the stored event by request ID with a secret key, check it belongs to this action, then decide. Anything a browser or an app returns can be edited by whoever controls it: the visitor ID, the score, the verdicts. Verification closes that gap. Your backend reads the stored event from Fingerly with a secret key, confirms it belongs to the action being taken, and only then decides. ## The flow ### Step 1: The client identifies Call `identify({ tag })` when the visitor acts. Send only the `requestId` to your backend, with the action. ### Step 2: Your server reads the event Fetch the event by request ID with your secret key. The answer comes from Fingerly, not from the browser. ```bash Request curl "https://us.api.fingerly.io/api/v1/events/01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4" \ -H "x-api-key: $FINGERLY_SECRET_KEY" ``` ### Step 3: Your server checks it Run the four checks below. ### Step 4: Your server decides Allow, challenge, review or refuse, and record the request ID with the outcome. ## Four checks | Check | How | Stops | | --- | --- | --- | | It exists | The read succeeds. A `404` means no such event in this key's environment. | Made-up or cross-environment request IDs. | | It is this action | `tag` equals what you expect, such as `checkout:8412`. | An identification from a harmless page replayed at checkout. | | It is recent | `occurred_at` is within the window your flow allows, such as two minutes. | Old request IDs saved and reused later. | | It passes your policy | Read `suspect_level`, `triggers` and `visitor_id`. | The fraud you integrated Fingerly for. | ## In code The same four checks, with each server SDK. ```ts Node.js import { load, FingerlyAPIError } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const MAX_AGE_MS = 2 * 60 * 1000 export async function decide(orderId: string, requestId: string) { let event try { event = await fingerly.events.get(requestId) } catch (error) { if (error instanceof FingerlyAPIError && error.status === 404) return 'refuse' throw error } if (event.tag !== 'checkout:' + orderId) return 'refuse' if (Date.now() - Date.parse(event.occurred_at) > MAX_AGE_MS) return 'refuse' if (event.suspect_level === 'high') return 'review' if (event.suspect_level === 'medium') return 'challenge' return 'allow' } ``` ```python Python from datetime import datetime, timedelta, timezone from fingerly import Fingerly, FingerlyAPIError fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) def decide(order_id: str, request_id: str) -> str: try: event = fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404: return "refuse" raise if event.tag != f"checkout:{order_id}": return "refuse" if datetime.now(timezone.utc) - event.occurred_at > timedelta(minutes=2): return "refuse" if event.suspect_level == "high": return "review" if event.suspect_level == "medium": return "challenge" return "allow" ``` ```python Python (async) from datetime import datetime, timedelta, timezone from fingerly import AsyncFingerly, FingerlyAPIError fingerly = AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) async def decide(order_id: str, request_id: str) -> str: try: event = await fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404: return "refuse" raise if event.tag != f"checkout:{order_id}": return "refuse" if datetime.now(timezone.utc) - event.occurred_at > timedelta(minutes=2): return "refuse" return {"high": "review", "medium": "challenge"}.get(event.suspect_level, "allow") ``` ```go Go func decide(ctx context.Context, orderID, requestID string) (string, error) { event, err := client.Events.Get(ctx, requestID) var apiErr *fingerly.APIError if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound { return "refuse", nil } else if err != nil { return "", err } if event.Tag != "checkout:"+orderID || time.Since(event.OccurredAt) > 2*time.Minute { return "refuse", nil } switch event.SuspectLevel { case "high": return "review", nil case "medium": return "challenge", nil } return "allow", nil } ``` ```java Java public String decide(String orderId, String requestId) { Event event; try { event = fingerly.events().get(requestId); } catch (FingerlyApiException e) { if (e.getStatus() == 404) return "refuse"; throw e; } if (!("checkout:" + orderId).equals(event.getTag())) return "refuse"; if (event.getOccurredAt().isBefore(Instant.now().minus(Duration.ofMinutes(2)))) return "refuse"; return switch (String.valueOf(event.getSuspectLevel())) { case "high" -> "review"; case "medium" -> "challenge"; default -> "allow"; }; } ``` ```csharp .NET public async Task 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", }; } ``` ```php PHP events->get($requestId); } catch (ApiException $e) { if ($e->getStatus() === 404) { return 'refuse'; } throw $e; } if ($event->tag !== "checkout:{$orderId}") { return 'refuse'; } if ($event->occurredAt < new DateTimeImmutable('-2 minutes')) { return 'refuse'; } return match ($event->suspectLevel) { 'high' => 'review', 'medium' => 'challenge', default => 'allow', }; } ``` ```ruby Ruby def decide(order_id, request_id) event = fingerly.events.get(request_id) return "refuse" unless event.tag == "checkout:#{order_id}" return "refuse" if event.occurred_at < Time.now - 120 case event.suspect_level when "high" then "review" when "medium" then "challenge" else "allow" end rescue Fingerly::APIError => e raise unless e.status == 404 "refuse" end ``` ```rust Rust async fn decide(fingerly: &fingerly::Client, order_id: &str, request_id: &str) -> Result { let event = match fingerly.events().get(request_id).await { Ok(event) => event, Err(fingerly::Error::Api { status: 404, .. }) => return Ok(Decision::Refuse), Err(error) => return Err(error), }; if event.tag.as_deref() != Some(&format!("checkout:{order_id}")) { return Ok(Decision::Refuse); } if chrono::Utc::now() - event.occurred_at > chrono::Duration::minutes(2) { return Ok(Decision::Refuse); } Ok(match event.suspect_level { Some(Level::High) => Decision::Review, Some(Level::Medium) => Decision::Challenge, _ => Decision::Allow, }) } ``` ## Timing An event is usually readable within a few seconds of the identification. If your client sends the request ID in the same moment it receives it, retry a `404` a few times over a few seconds before refusing. > **Tip:** If you only need the verdict at the moment of the action, the identify response already contains it. Verification is what makes it trustworthy: read the event when the decision matters. ## Keep secret keys secret - Secret keys are refused when a request carries an `Origin` header, so they cannot be used from front-end code. - A secret key only reads its own environment. Use a production secret key to verify production identifications. - Store keys in your secret manager, and revoke and replace one immediately if it leaks. ## Without a request ID If identification failed in the client, your server receives no request ID. Treat that as missing evidence rather than as proof of fraud or of innocence: for example, allow low-risk actions and require a second factor for high-risk ones. > **Warning:** Do not accept a score, visitor ID or verdict sent by the client in place of a request ID. Only the stored event is trustworthy. --- # Webhooks > Receive identifications, high-risk visitors, refusals, billing changes and daily usage as signed HTTPS requests to your server, the moment they happen. Webhooks are the server API in reverse: instead of reading events from Fingerly, Fingerly sends them to an HTTPS endpoint on your server as they happen. Use them to: - Keep your own copy of every identification for as long as you need it. Fingerly keeps events readable for 30 days. - React to high-risk visitors in your fraud tooling without polling. - Alert on refused requests before they become an outage. - Reconcile usage and billing with your own records. Webhooks are delivered asynchronously and add no latency to identification. ## Events | Event | Sent when | | --- | --- | | [`identification.completed`](https://docs.fingerly.io/reference/webhooks/identification-completed) | Any identification finishes. | | [`visitor.suspect`](https://docs.fingerly.io/reference/webhooks/visitor-suspect) | An identification reaches the `high` level. | | [`identification.refused`](https://docs.fingerly.io/reference/webhooks/identification-refused) | An identify request is refused. | | [`billing.status_changed`](https://docs.fingerly.io/reference/webhooks/billing-status-changed) | Your organization starts or stops accepting traffic. | | [`usage.daily_settled`](https://docs.fingerly.io/reference/webhooks/usage-daily-settled) | A day of usage is settled. | ## Implement a handler Create a route that accepts a `POST` with a JSON body, verifies the signature, hands the event to your own queue, and returns `2xx` quickly. ```ts Next.js // app/webhooks/fingerly/route.ts import { verifyWebhook } from '@fingerly/node' export async function POST(request: Request) { const payload = await request.text() const valid = verifyWebhook({ secret: process.env.FINGERLY_WEBHOOK_SECRET!, payload, timestamp: request.headers.get('x-fingerly-timestamp'), signature: request.headers.get('x-fingerly-signature'), }) if (!valid) return new Response('invalid signature', { status: 400 }) const event = JSON.parse(payload) await queue.add(event.id, event) // deduplicate on event.id return new Response(null, { status: 204 }) } ``` ```ts Node.js import express from 'express' import { verifyWebhook } from '@fingerly/node' app.post('/webhooks/fingerly', express.raw({ type: 'application/json' }), async (req, res) => { const valid = verifyWebhook({ secret: process.env.FINGERLY_WEBHOOK_SECRET!, payload: req.body, timestamp: req.get('x-fingerly-timestamp'), signature: req.get('x-fingerly-signature'), }) if (!valid) return res.sendStatus(400) const event = JSON.parse(req.body.toString('utf8')) await queue.add(event.id, event) // deduplicate on event.id res.sendStatus(204) }) ``` ```python Python from flask import Flask, abort, request from fingerly import verify_webhook @app.post("/webhooks/fingerly") def fingerly_webhook(): payload = request.get_data() if not verify_webhook( secret=os.environ["FINGERLY_WEBHOOK_SECRET"], payload=payload, timestamp=request.headers.get("x-fingerly-timestamp"), signature=request.headers.get("x-fingerly-signature"), ): abort(400) event = json.loads(payload) queue.enqueue(event["id"], event) # deduplicate on the event ID return "", 204 ``` ```python Python (async) from fastapi import FastAPI, HTTPException, Request, Response from fingerly import verify_webhook @app.post("/webhooks/fingerly", status_code=204) async def fingerly_webhook(request: Request) -> Response: payload = await request.body() if not verify_webhook( secret=os.environ["FINGERLY_WEBHOOK_SECRET"], payload=payload, timestamp=request.headers.get("x-fingerly-timestamp"), signature=request.headers.get("x-fingerly-signature"), ): raise HTTPException(status_code=400) event = json.loads(payload) await queue.enqueue(event["id"], event) return Response(status_code=204) ``` ```go Go func fingerlyWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "unreadable body", http.StatusBadRequest) return } if !fingerly.VerifyWebhook( os.Getenv("FINGERLY_WEBHOOK_SECRET"), body, r.Header.Get("X-Fingerly-Timestamp"), r.Header.Get("X-Fingerly-Signature"), ) { http.Error(w, "invalid signature", http.StatusBadRequest) return } var event fingerly.WebhookEvent _ = json.Unmarshal(body, &event) enqueue(event.ID, body) // deduplicate on the event ID w.WriteHeader(http.StatusNoContent) } ``` ```java Java @PostMapping("/webhooks/fingerly") public ResponseEntity receive( @RequestBody byte[] body, @RequestHeader("x-fingerly-timestamp") String timestamp, @RequestHeader("x-fingerly-signature") String signature) { if (!Webhooks.verify(webhookSecret, body, timestamp, signature)) { return ResponseEntity.badRequest().build(); } WebhookEvent event = Webhooks.parse(body); events.enqueue(event.getId(), body); // deduplicate on the event ID return ResponseEntity.noContent().build(); } ``` ```csharp .NET app.MapPost("/webhooks/fingerly", async (HttpRequest request, IEventQueue queue) => { using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(); var valid = FingerlyWebhook.Verify( secret: builder.Configuration["Fingerly:WebhookSecret"]!, payload: body, timestamp: request.Headers["x-fingerly-timestamp"], signature: request.Headers["x-fingerly-signature"]); if (!valid) return Results.BadRequest(); var ev = FingerlyWebhook.Parse(body); await queue.EnqueueAsync(ev.Id, body); // deduplicate on the event ID return Results.NoContent(); }); ``` ```php PHP getContent(), timestamp: $request->header('x-fingerly-timestamp'), signature: $request->header('x-fingerly-signature'), ); abort_unless($valid, 400); ProcessFingerlyEvent::dispatch($request->json()->all()); return response()->noContent(); }); ``` ```ruby Ruby class FingerlyWebhooksController < ActionController::API def create payload = request.raw_post valid = Fingerly::Webhook.verify( secret: ENV.fetch("FINGERLY_WEBHOOK_SECRET"), payload: payload, timestamp: request.headers["x-fingerly-timestamp"], signature: request.headers["x-fingerly-signature"], ) return head :bad_request unless valid event = JSON.parse(payload) FingerlyEventJob.perform_later(event) # deduplicate on event["id"] head :no_content end end ``` ```rust Rust async fn fingerly_webhook(State(state): State, headers: HeaderMap, body: Bytes) -> StatusCode { let header = |name| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default(); if !fingerly::webhook::verify( &state.webhook_secret, &body, header("x-fingerly-timestamp"), header("x-fingerly-signature"), ) { return StatusCode::BAD_REQUEST; } let event: fingerly::WebhookEvent = serde_json::from_slice(&body).unwrap(); state.queue.enqueue(event.id.clone(), body).await; // deduplicate on the event ID StatusCode::NO_CONTENT } ``` ### Responses and timeouts - Any `2xx` response completes the delivery. - `410 Gone` stops the delivery permanently, without retries. - Any other response, a timeout or a connection failure is retried. - Fingerly waits 10 seconds for a response. Acknowledge first and do slow work from your own queue. - Redirects are not followed. Register the final URL. ### Retries A failed delivery is attempted up to six times in total. The retries come after: | Retry | 1 | 2 | 3 | 4 | 5 | | --- | --- | --- | --- | --- | --- | | Delay after the previous attempt | 30 seconds | 2 minutes | 10 minutes | 1 hour | 6 hours | After the last attempt fails, the delivery is marked failed. An endpoint whose deliveries keep failing is shown as **failing** in the dashboard, and keeps receiving new events. ### Duplicates and ordering Delivery is at least once: when an outcome is ambiguous, such as a timeout after your server processed the request, the same event can arrive again. Its `id` never changes, so deduplicate on it. Events can arrive out of order; use `created_at` when order matters. ## Register an endpoint ### Step 1: Open Webhooks In the dashboard, go to **Integration > Webhooks** and add an endpoint. ### Step 2: Configure it - **URL**: an `https://` URL that resolves to a public address. - **Environment**: **Live** receives production traffic; **Test** receives staging and development traffic. - **Events**: the event types to receive. - **Description**: optional, to tell endpoints apart. ### Step 3: Save the signing secret The signing secret, `whsec_…`, is shown once, when the endpoint is created. Store it in your server's secret manager. - An organization can have up to 10 endpoints across both environments. - Owners, admins and developers can manage webhooks. - `billing.status_changed` is available to live endpoints only. - Pause an endpoint during maintenance on your side. A paused endpoint is not sent new events; deliveries already queued for it wait and are sent when you resume it. ## Verify the signature Every delivery is signed with the endpoint's secret. Verify it before you parse or act on the body: anyone can send a request to a public URL. | Header | Value | | --- | --- | | `X-Fingerly-Timestamp` | Unix seconds when the attempt was signed. | | `X-Fingerly-Signature` | `sha256=` and the lowercase hex HMAC-SHA256 of `timestamp.body`. During a [secret rotation](#rotate-a-secret), one signature per secret, separated by commas. | | `X-Fingerly-Event-ID` | The event ID, for deduplication. | | `X-Fingerly-Event-Type` | The event type, for routing. | Every server SDK's helper does this for you, as in the handlers above. Without an SDK, the check is one HMAC: ```text Pseudocode signed = timestamp + "." + raw_body expected = hex(hmac_sha256(key = signing_secret, message = signed)) valid = any( constant_time_equal(expected, candidate without "sha256=") for candidate in signature split on "," ) and abs(now - timestamp) <= 300 seconds ``` ```ts Node.js (no SDK) import { createHmac, timingSafeEqual } from 'node:crypto' function verify(secret: string, body: Buffer, timestamp: string, signature: string) { if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false const expected = createHmac('sha256', secret).update(timestamp + '.').update(body).digest('hex') // During a secret rotation the header holds one signature per secret. return signature.split(',').some((candidate) => { const value = candidate.trim() if (!value.startsWith('sha256=')) return false const received = value.slice('sha256='.length) return expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received)) }) } ``` > **Warning:** Compute the signature over the raw body bytes. Most frameworks parse JSON before your handler runs; configure the route to give you the raw body instead. ## Rotate a secret In **Integration > Webhooks**, open the endpoint's menu and choose **Rotate secret**. The new secret is shown once. Choose how long the old secret stays valid, from immediately up to seven days; the default is 24 hours. Until then, every delivery carries two signatures in `X-Fingerly-Signature`, separated by a comma: the new secret's first, then the old one's. Accept a delivery when any of them verifies, as the SDK helpers and the samples above do, and deploy the new secret at any point in the window. > **Warning:** A verifier that compares the whole header against one signature rejects every delivery during the window. Update it before you rotate. Rotating again before the window ends ends it: only the secret being replaced stays valid alongside the new one. ## Delivery history **Integration > Webhooks** lists every delivery attempt from the last 30 days with its event, status (`delivered`, `retrying` or `failed`), response code, duration and attempt number, for live and test endpoints. - **Redeliver** sends an event from the history to the same endpoint again, with the same `id`. It is one attempt, and it does not affect the retries of the original delivery. - **Send test event** sends a signed [`webhook.test`](https://docs.fingerly.io/reference/webhooks/envelope#event-types) event to one endpoint, in one attempt, so you can check your URL and signature verification. - A failed test or redelivery does not mark the endpoint as failing. Neither is available while the endpoint is paused, and only one at a time can be queued for an endpoint. ## Test locally Webhook URLs must be public, so expose your local server with a tunnel such as `cloudflared` or `ngrok`, register the tunnel's URL on a **Test** endpoint, and identify with a development key. ```bash Terminal cloudflared tunnel --url http://localhost:3000 ``` > **Note:** Test endpoints only ever receive staging and development traffic, so a local receiver never sees production events. - [Event envelope](https://docs.fingerly.io/reference/webhooks/envelope): Headers, signature and envelope fields. - [Reading events](https://docs.fingerly.io/docs/reading-events): Backfill or reconcile with the server API. --- # Reading events > Read identification events with a secret key: one event by request ID, or a window of events filtered by visitor or level, for up to 30 days. Every identification is stored as an event you can read with a secret key for 30 days. Read one to [verify an identification](https://docs.fingerly.io/docs/server-side-verification), or list a window of them to investigate a visitor, backfill your warehouse or reconcile with webhooks. ## One event ```ts Node.js import { load } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const event = await fingerly.events.get('01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4') ``` ```python Python import os from fingerly import Fingerly fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```python Python (async) import os from fingerly import AsyncFingerly fingerly = AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) event = await fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```go Go import fingerly "github.com/fingerly-io/fingerly-go" client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY")) event, err := client.Events.Get(ctx, "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```java Java import io.fingerly.server.FingerlyClient; FingerlyClient fingerly = FingerlyClient.builder() .secretKey(System.getenv("FINGERLY_SECRET_KEY")) .build(); Event event = fingerly.events().get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4"); ``` ```csharp .NET using Fingerly; var fingerly = new FingerlyClient(Environment.GetEnvironmentVariable("FINGERLY_SECRET_KEY")); var ev = await fingerly.Events.GetAsync("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4"); ``` ```php PHP events->get('01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4'); ``` ```ruby Ruby require "fingerly" fingerly = Fingerly::Client.new(secret_key: ENV.fetch("FINGERLY_SECRET_KEY")) event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```rust Rust let fingerly = fingerly::Client::new(std::env::var("FINGERLY_SECRET_KEY")?); let event = fingerly.events().get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4").await?; ``` ```bash cURL curl "https://us.api.fingerly.io/api/v1/events/01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4" \ -H "x-api-key: $FINGERLY_SECRET_KEY" ``` A single event also includes the archived submission as `document`, with the threshold and weights profile that scored it, and any deferred report as `deferred_signals`. See [Get an event](https://docs.fingerly.io/reference/get-event). ## A window of events ```ts Node.js const page = await fingerly.events.list({ level: 'high', limit: 50 }) ``` ```python Python page = fingerly.events.list(level="high", limit=50) ``` ```python Python (async) page = await fingerly.events.list(level="high", limit=50) ``` ```go Go page, err := client.Events.List(ctx, &fingerly.EventListParams{Level: "high", Limit: 50}) ``` ```java Java EventPage page = fingerly.events().list(EventListParams.builder().level("high").limit(50).build()); ``` ```csharp .NET var page = await fingerly.Events.ListAsync(new EventListOptions { Level = "high", Limit = 50 }); ``` ```php PHP $page = $fingerly->events->list(['level' => 'high', 'limit' => 50]); ``` ```ruby Ruby page = fingerly.events.list(level: "high", limit: 50) ``` ```rust Rust let page = fingerly.events().list(&ListEvents { level: Some(Level::High), limit: Some(50), ..Default::default() }).await?; ``` ```bash cURL curl "https://us.api.fingerly.io/api/v1/events?level=high&limit=50" \ -H "x-api-key: $FINGERLY_SECRET_KEY" ``` | Parameter | Default | Notes | | --- | --- | --- | | `from`, `to` | The last 24 hours | RFC 3339. At most 30 days wide, starting at most 30 days ago. | | `page`, `limit` | `1`, `10` | `limit` is at most 200. Pages reach back at most 10,000 events. | | `visitor` | none | One exact visitor ID. | | `level` | none | `low`, `medium` or `high`. | ## Everything a visitor did ```ts Node.js const history = await fingerly.events.list({ visitor: 'X9pL2mRc7KvT4bQw8NdF', from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), limit: 200, }) ``` ```python Python history = fingerly.events.list( visitor="X9pL2mRc7KvT4bQw8NdF", from_=datetime.now(timezone.utc) - timedelta(days=30), limit=200, ) ``` ```python Python (async) history = await fingerly.events.list( visitor="X9pL2mRc7KvT4bQw8NdF", from_=datetime.now(timezone.utc) - timedelta(days=30), limit=200, ) ``` ## Paging through a window ```ts Node.js async function* allEvents(from: Date, to: Date) { for (let page = 1; ; page++) { const result = await fingerly.events.list({ from, to, page, limit: 200 }) yield* result.rows if (result.rows.length < result.page_size) return } } ``` ```python Python (async) async def all_events(from_: datetime, to: datetime): page = 1 while True: result = await fingerly.events.list(from_=from_, to=to, page=page, limit=200) for event in result.rows: yield event if len(result.rows) < result.page_size: return page += 1 ``` > **Tip:** A page reaches back at most 10,000 events. To export more, split the window into smaller windows, or keep your own copy with the `identification.completed` [webhook](https://docs.fingerly.io/docs/webhooks). ## Triggers in events Events group their triggers by [signal group](https://docs.fingerly.io/docs/signals): a trigger's `signal` is the group, such as `vpn`, and its `weight` is what the group added. The identify response and the `visitor.suspect` webhook list the individual signals. > **Note:** Each secret key reads only its own environment. Reads are free and do not count against your rate limit. --- # Use cases > End-to-end recipes for account takeover, credential stuffing, sign-up abuse, promotion abuse and payment fraud: where to identify, what to tag and what to decide. Fingerly tells you who a visitor is and how suspicious the session looks. What to do about it depends on what the visitor is trying to do. Each recipe below takes one kind of fraud from the client to a decision on your server, with a policy you can start from. | Recipe | Identify at | What decides it | | --- | --- | --- | | [Account takeover](https://docs.fingerly.io/docs/use-cases/account-takeover) | Login, password reset, account changes, payouts | Whether the account has used this device before, and its level. | | [Credential stuffing](https://docs.fingerly.io/docs/use-cases/credential-stuffing) | Every login attempt | Automation, and how many failed attempts and accounts one device is behind. | | [Sign-up abuse](https://docs.fingerly.io/docs/use-cases/sign-up-abuse) | Account creation | How many accounts one device has created, and device farm and emulator signals. | | [Promotion abuse](https://docs.fingerly.io/docs/use-cases/promotion-abuse) | Redeeming a code, a referral or a trial | One redemption per device per promotion. | | [Payment fraud](https://docs.fingerly.io/docs/use-cases/payment-fraud) | Checkout, adding a card | The level, anonymised networks, and how many cards one device tries. | ## What every recipe shares - **A tag per action.** The tag binds an identification to what it was made for, so a request ID from a harmless page cannot be spent at checkout. - **A server-side read.** Decisions use the event your backend reads with a secret key, never what the client reports. - **Your own records.** Fingerly returns the visitor ID; counting accounts, failed logins or redemptions per visitor happens in your database. Store `visitor_id` with every account, login, order and redemption. - **Failure is missing evidence.** When identification fails, the visitor continues and your server decides with less information. ## The shared server helper Every recipe starts the same way: read the stored event with a secret key, and refuse to trust it unless it belongs to this action, is recent, and has not been used before. Fingerly does not stop a request ID from being read twice, so the one-time check is yours: any store with an atomic "add if absent" works, such as Redis `SET` with `NX` and a ten-minute expiry. ```ts Node.js // fingerly.server.ts import { load, FingerlyAPIError } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) async function readEvent(requestId: string) { for (let attempt = 1; ; attempt++) { try { return await fingerly.events.get(requestId) } catch (error) { const status = error instanceof FingerlyAPIError ? error.status : undefined if (status === 404 && attempt < 4) await sleep(attempt * 250) // not readable yet else if (status === 404 || status === 422) return null else throw error } } } /** * The stored event behind a request ID, or null when there is nothing to * trust: no ID, an unknown ID, another action's tag, too old, or used before. */ export async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) { if (typeof requestId !== 'string' || requestId === '') return null const event = await readEvent(requestId) if (!event || event.tag !== tag) return null if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null if (!(await usedRequestIds.add(event.request_id))) return null // your store: true only the first time return event } /** Whether any of these signal groups fired. */ export const fired = (event: { triggers: Array<{ signal: string }> }, ...groups: string[]) => event.triggers.some((trigger) => groups.includes(trigger.signal)) ``` ```python Python # fingerly_server.py import os import time from datetime import datetime, timedelta, timezone from fingerly import Fingerly, FingerlyAPIError fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) def _read_event(request_id): for attempt in range(1, 5): try: return fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404 and attempt < 4: time.sleep(attempt * 0.25) # not readable yet elif error.status in (404, 422): return None else: raise def verified_event(request_id, tag, max_age=timedelta(minutes=2)): """The stored event behind a request ID, or None when there is nothing to trust.""" if not isinstance(request_id, str) or not request_id: return None event = _read_event(request_id) if event is None or event.tag != tag: return None if datetime.now(timezone.utc) - event.occurred_at > max_age: return None if not used_request_ids.add(event.request_id): # your store: True only the first time return None return event def fired(event, *groups): """Whether any of these signal groups fired.""" return any(trigger.signal in groups for trigger in event.triggers) ``` `verifiedEvent` returns nothing when the client could not identify the visitor at all. Treat that as missing evidence: the policies below add friction a real customer can pass, rather than refusing outright. > **Tip:** New to the flow? Read [server-side verification](https://docs.fingerly.io/docs/server-side-verification) first. It explains the checks this helper makes. - [Account takeover](https://docs.fingerly.io/docs/use-cases/account-takeover): Known devices through, new ones challenged. - [Credential stuffing](https://docs.fingerly.io/docs/use-cases/credential-stuffing): Stop scripted logins at the door. - [Sign-up abuse](https://docs.fingerly.io/docs/use-cases/sign-up-abuse): Limit accounts per device. - [Promotion abuse](https://docs.fingerly.io/docs/use-cases/promotion-abuse): One redemption per device. - [Payment fraud](https://docs.fingerly.io/docs/use-cases/payment-fraud): Review risky orders, stop card testing. - [Migrate from FingerprintJS Pro](https://docs.fingerly.io/docs/migrate-from-fingerprintjs): Map your existing integration. --- # Account takeover > Remember the devices each account uses, let them through with little friction, and challenge logins, resets and payouts from new or suspicious devices. In an account takeover, someone other than the customer gets into their account, usually with a password stolen from another site, phished, or reset through a hijacked email address. To your login form the attacker looks like the customer: the right email and the right password. The device is what differs. This recipe gives each account a list of devices it is known to use. Known devices get through with little friction, and the rest are challenged in proportion to how suspicious they look. ## Where to identify | Moment | Tag | Why | | --- | --- | --- | | Login | `login` | Where a stolen password is first used. | | Password reset request | `password-reset` | Many takeovers start with a reset from a device the account has never used. | | Changing the email, phone number or second factor | `account-change` | Attackers lock the owner out before they act. | | Payout, withdrawal or new payee | `payout:` | Where a takeover becomes a loss. | ## Identify in the client ```ts JavaScript import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…' }) async function submit() { let requestId: string | undefined try { ({ requestId } = await fingerly.identify({ tag: 'login' })) } catch { // Carry on: your server treats a missing request ID as missing evidence. } await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, requestId }) }) } ``` ```swift Swift let requestId = try? await fingerly.identify(tag: "login").requestId try await api.signIn(email: email, password: password, requestId: requestId) ``` ```kotlin Kotlin val requestId = runCatching { fingerly.identify(tag = "login").requestId }.getOrNull() api.signIn(email, password, requestId) ``` ## Read the event on your server Every recipe starts the same way: read the stored event with a secret key, and refuse to trust it unless it belongs to this action, is recent, and has not been used before. Fingerly does not stop a request ID from being read twice, so the one-time check is yours: any store with an atomic "add if absent" works, such as Redis `SET` with `NX` and a ten-minute expiry. ```ts Node.js // fingerly.server.ts import { load, FingerlyAPIError } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) async function readEvent(requestId: string) { for (let attempt = 1; ; attempt++) { try { return await fingerly.events.get(requestId) } catch (error) { const status = error instanceof FingerlyAPIError ? error.status : undefined if (status === 404 && attempt < 4) await sleep(attempt * 250) // not readable yet else if (status === 404 || status === 422) return null else throw error } } } /** * The stored event behind a request ID, or null when there is nothing to * trust: no ID, an unknown ID, another action's tag, too old, or used before. */ export async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) { if (typeof requestId !== 'string' || requestId === '') return null const event = await readEvent(requestId) if (!event || event.tag !== tag) return null if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null if (!(await usedRequestIds.add(event.request_id))) return null // your store: true only the first time return event } /** Whether any of these signal groups fired. */ export const fired = (event: { triggers: Array<{ signal: string }> }, ...groups: string[]) => event.triggers.some((trigger) => groups.includes(trigger.signal)) ``` ```python Python # fingerly_server.py import os import time from datetime import datetime, timedelta, timezone from fingerly import Fingerly, FingerlyAPIError fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) def _read_event(request_id): for attempt in range(1, 5): try: return fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404 and attempt < 4: time.sleep(attempt * 0.25) # not readable yet elif error.status in (404, 422): return None else: raise def verified_event(request_id, tag, max_age=timedelta(minutes=2)): """The stored event behind a request ID, or None when there is nothing to trust.""" if not isinstance(request_id, str) or not request_id: return None event = _read_event(request_id) if event is None or event.tag != tag: return None if datetime.now(timezone.utc) - event.occurred_at > max_age: return None if not used_request_ids.add(event.request_id): # your store: True only the first time return None return event def fired(event, *groups): """Whether any of these signal groups fired.""" return any(trigger.signal in groups for trigger in event.triggers) ``` `verifiedEvent` returns nothing when the client could not identify the visitor at all. Treat that as missing evidence: the policies below add friction a real customer can pass, rather than refusing outright. ## Decide Check the password first, as you do today. Only for a correct password, decide what the device means. ```ts Node.js import { verifiedEvent } from './fingerly.server' export async function afterPasswordCheck(accountId: string, requestId: unknown) { const event = await verifiedEvent(requestId, 'login') if (!event) return { action: 'second-factor' } const known = await knownDevices.has(accountId, event.visitor_id) if (event.suspect_level === 'high') return { action: known ? 'second-factor' : 'block', event } if (known) return { action: 'allow', event } if (event.suspect_level === 'low') return { action: 'allow-and-notify', event } return { action: 'second-factor', event } // medium, or not scored } // Once the customer is fully signed in, including any second factor: export async function onSignedIn(accountId: string, visitorId: string) { await knownDevices.add(accountId, visitorId) } ``` ```python Python from fingerly_server import verified_event def after_password_check(account_id, request_id): event = verified_event(request_id, "login") if event is None: return "second-factor", None known = known_devices.has(account_id, event.visitor_id) if event.suspect_level == "high": return ("second-factor" if known else "block"), event if known: return "allow", event if event.suspect_level == "low": return "allow-and-notify", event return "second-factor", event # medium, or not scored # Once the customer is fully signed in, including any second factor: def on_signed_in(account_id, visitor_id): known_devices.add(account_id, visitor_id) ``` ## A starting policy | Level | Known device | New device | | --- | --- | --- | | `low` | Allow. | Allow, and tell the customer about a sign-in from a new device. | | `medium`, or not scored | Allow. | Ask for a second factor. | | `high` | Ask for a second factor. | Refuse the attempt without saying why, and tell the customer. | | No usable identification | Ask for a second factor. | Ask for a second factor. | Use the same table for password resets, account changes and payouts, with their own tags. For payouts, consider treating `medium` on a new device as `high`. ## Known devices - Add a device only after a sign-in that fully succeeded, including any second factor. Otherwise an attacker's failed attempt would make their device known. - Keep `last_seen` with each device, and forget devices that have not been seen for a few months. A visitor identity Fingerly has not seen for 180 days is issued a new visitor ID anyway. - A device whose browser hides almost everything gets a new visitor ID every time, so it is never known. It raises `fingerprint_suppressed`, which alone scores `medium` with the default weights, so these customers are asked for a second factor. - A known device lowers friction. It is never a reason to skip the password. ## Signals that matter here | Group | Why it matters for takeover | | --- | --- | | `tor`, `datacenter_proxy`, `residential_proxy`, `vpn` | Attackers hide where they are, and rotate addresses to get past per-address limits. | | `location_spoofing` | The device pretends to be somewhere else, often near the victim. | | `bot`, `browser_tampering`, `virtual_machine` | Takeover tooling automates logins and disguises the browser. | | `android_emulator`, `ios_simulator`, `rooted_device`, `jailbroken_device`, `frida_detected` | In apps, takeovers run from emulators and modified devices. | | `active_call` | On a payout, a customer on a phone call may be being coached by a scammer. | To make one of these decisive, read it from `event.triggers` with `fired(event, …)`, or raise its weight in [risk weights](https://docs.fingerly.io/docs/risk-weights). ## Roll it out - **Observe first.** Run the check and log the decision it would have made, next to what actually happened, for a week or two. - **Tune.** Look at the sessions the policy would have stopped in **Identification > Events**, and adjust [risk weights](https://docs.fingerly.io/docs/risk-weights) and the thresholds in your own code until they match what you see. - **Enforce gradually.** Turn on the friction a real customer can pass before the outright blocks. - [Credential stuffing](https://docs.fingerly.io/docs/use-cases/credential-stuffing): Stop the scripts before they find a password. - [Visitor identification](https://docs.fingerly.io/docs/visitor-identification): What a visitor ID is, and when it changes. --- # Credential stuffing > Stop scripts that replay leaked passwords against your login: refuse automation, and limit failed attempts and accounts per device instead of per address. Credential stuffing replays email and password pairs leaked from other sites against your login, hoping some customers reused them. It is automated, fast, and spread across many addresses so per-address rate limits never trigger. It succeeds quietly: the attacker ends up with a list of working logins to take over later. Device identification changes what you can count. Addresses rotate for free; the devices and scripts behind them are far fewer. ## Where to identify | Moment | Tag | Why | | --- | --- | --- | | Every login attempt, before the password is checked | `login` | The attack is the attempts themselves, successful or not. | | Login endpoints of your API and apps | `login` | Scripts go wherever the form is weakest. | ## Identify in the client ```ts JavaScript import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…' }) async function submit() { let requestId: string | undefined try { ({ requestId } = await fingerly.identify({ tag: 'login' })) } catch { // Carry on: your server treats a missing request ID as missing evidence. } await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, requestId }) }) } ``` ```swift Swift let requestId = try? await fingerly.identify(tag: "login").requestId try await api.signIn(email: email, password: password, requestId: requestId) ``` ```kotlin Kotlin val requestId = runCatching { fingerly.identify(tag = "login").requestId }.getOrNull() api.signIn(email, password, requestId) ``` ## Read the event on your server Every recipe starts the same way: read the stored event with a secret key, and refuse to trust it unless it belongs to this action, is recent, and has not been used before. Fingerly does not stop a request ID from being read twice, so the one-time check is yours: any store with an atomic "add if absent" works, such as Redis `SET` with `NX` and a ten-minute expiry. ```ts Node.js // fingerly.server.ts import { load, FingerlyAPIError } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) async function readEvent(requestId: string) { for (let attempt = 1; ; attempt++) { try { return await fingerly.events.get(requestId) } catch (error) { const status = error instanceof FingerlyAPIError ? error.status : undefined if (status === 404 && attempt < 4) await sleep(attempt * 250) // not readable yet else if (status === 404 || status === 422) return null else throw error } } } /** * The stored event behind a request ID, or null when there is nothing to * trust: no ID, an unknown ID, another action's tag, too old, or used before. */ export async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) { if (typeof requestId !== 'string' || requestId === '') return null const event = await readEvent(requestId) if (!event || event.tag !== tag) return null if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null if (!(await usedRequestIds.add(event.request_id))) return null // your store: true only the first time return event } /** Whether any of these signal groups fired. */ export const fired = (event: { triggers: Array<{ signal: string }> }, ...groups: string[]) => event.triggers.some((trigger) => groups.includes(trigger.signal)) ``` ```python Python # fingerly_server.py import os import time from datetime import datetime, timedelta, timezone from fingerly import Fingerly, FingerlyAPIError fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) def _read_event(request_id): for attempt in range(1, 5): try: return fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404 and attempt < 4: time.sleep(attempt * 0.25) # not readable yet elif error.status in (404, 422): return None else: raise def verified_event(request_id, tag, max_age=timedelta(minutes=2)): """The stored event behind a request ID, or None when there is nothing to trust.""" if not isinstance(request_id, str) or not request_id: return None event = _read_event(request_id) if event is None or event.tag != tag: return None if datetime.now(timezone.utc) - event.occurred_at > max_age: return None if not used_request_ids.add(event.request_id): # your store: True only the first time return None return event def fired(event, *groups): """Whether any of these signal groups fired.""" return any(trigger.signal in groups for trigger in event.triggers) ``` `verifiedEvent` returns nothing when the client could not identify the visitor at all. Treat that as missing evidence: the policies below add friction a real customer can pass, rather than refusing outright. ## Decide Decide before you check the password, so a script learns nothing from attempts you refuse. Count failures and distinct accounts per visitor in a store with expiring counters. ```ts Node.js import { fired, verifiedEvent } from './fingerly.server' export async function beforePasswordCheck(email: string, requestId: unknown) { const event = await verifiedEvent(requestId, 'login') // A script calling your login endpoint directly never ran the SDK. if (!event) return { action: 'captcha' } if (fired(event, 'bot')) return { action: 'refuse', event } const failures = await counters.get('login-failures:' + event.visitor_id) // last 15 minutes const accounts = await counters.distinct('login-accounts:' + event.visitor_id) // last hour await counters.addDistinct('login-accounts:' + event.visitor_id, email, { ttl: '1h' }) if (failures >= 10 || accounts >= 5) return { action: 'refuse', event } if (failures >= 3 || event.suspect_level !== 'low') return { action: 'captcha', event } if (fired(event, 'fingerprint_suppressed', 'datacenter_proxy', 'tor')) return { action: 'captcha', event } return { action: 'check-password', event } } export async function onWrongPassword(visitorId: string) { await counters.increment('login-failures:' + visitorId, { ttl: '15m' }) } ``` ```python Python from fingerly_server import fired, verified_event def before_password_check(email, request_id): event = verified_event(request_id, "login") # A script calling your login endpoint directly never ran the SDK. if event is None: return "captcha", None if fired(event, "bot"): return "refuse", event failures = counters.get(f"login-failures:{event.visitor_id}") # last 15 minutes accounts = counters.distinct(f"login-accounts:{event.visitor_id}") # last hour counters.add_distinct(f"login-accounts:{event.visitor_id}", email, ttl="1h") if failures >= 10 or accounts >= 5: return "refuse", event if failures >= 3 or event.suspect_level != "low": return "captcha", event if fired(event, "fingerprint_suppressed", "datacenter_proxy", "tor"): return "captcha", event return "check-password", event def on_wrong_password(visitor_id): counters.increment(f"login-failures:{visitor_id}", ttl="15m") ``` ## A starting policy | Situation | Action | | --- | --- | | No usable identification | Show a CAPTCHA before checking the password. | | `bot` fired | Refuse. | | 10 or more failed attempts, or 5 or more different accounts, from one visitor | Refuse for the rest of the window. | | 3 or more failed attempts, a level above `low`, or an unscored request | Show a CAPTCHA. | | `fingerprint_suppressed`, `datacenter_proxy` or `tor` fired | Show a CAPTCHA. These visitors cannot be counted reliably, or rarely log in this way. | | Otherwise | Check the password. | ## Why these rules - **The request ID is required.** Attack tools post straight to your login endpoint. Requiring a fresh, unused request ID tagged `login` means every attempt has to run the SDK, and the one-time check stops one identification being reused for thousands of attempts. - **Count per visitor, not per address.** Residential proxies give each attempt a new address. The visitor ID stays with the device. - **Count accounts, not only failures.** A real customer mistypes their own password. One device trying many different accounts is almost never a customer. - **Keep your address limits.** Device limits and address limits catch different attacks. Use both. > **Warning:** Answer refused attempts the same way you answer a wrong password, and with the same timing, so the script cannot tell which credentials are valid. > **Tip:** Watch `identification.refused` [webhooks](https://docs.fingerly.io/docs/webhooks) during an attack. A burst of `rate_limited` means the attack is reaching Fingerly faster than your organization's [rate limit](https://docs.fingerly.io/docs/rate-limits). ## Roll it out - **Observe first.** Run the check and log the decision it would have made, next to what actually happened, for a week or two. - **Tune.** Look at the sessions the policy would have stopped in **Identification > Events**, and adjust [risk weights](https://docs.fingerly.io/docs/risk-weights) and the thresholds in your own code until they match what you see. - **Enforce gradually.** Turn on the friction a real customer can pass before the outright blocks. - [Account takeover](https://docs.fingerly.io/docs/use-cases/account-takeover): What to do once the right password arrives. - [Signals reference](https://docs.fingerly.io/docs/signals): Every signal group and its default weight. --- # Sign-up abuse > Limit how many accounts one device can create, and send sign-ups from device farms, emulators and automation to verification or review. Fake and duplicate accounts are how most abuse starts: free trials claimed again and again, sign-up bonuses farmed, bans evaded, reviews and votes faked. Each account looks new, with a new email address. The device behind them usually is not. ## Where to identify | Moment | Tag | Why | | --- | --- | --- | | Submitting the sign-up form | `signup` | Decide before the account exists. | | Activating a trial or a free allowance | `trial-start` | Where a fake account turns into a cost, if activation is separate from sign-up. | ## Identify in the client ```ts JavaScript import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…' }) async function submit() { let requestId: string | undefined try { ({ requestId } = await fingerly.identify({ tag: 'signup' })) } catch { // Carry on: your server treats a missing request ID as missing evidence. } await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, requestId }) }) } ``` ```swift Swift let requestId = try? await fingerly.identify(tag: "signup").requestId try await api.signUp(email: email, requestId: requestId) ``` ```kotlin Kotlin val requestId = runCatching { fingerly.identify(tag = "signup").requestId }.getOrNull() api.signUp(email, requestId) ``` ## Read the event on your server Every recipe starts the same way: read the stored event with a secret key, and refuse to trust it unless it belongs to this action, is recent, and has not been used before. Fingerly does not stop a request ID from being read twice, so the one-time check is yours: any store with an atomic "add if absent" works, such as Redis `SET` with `NX` and a ten-minute expiry. ```ts Node.js // fingerly.server.ts import { load, FingerlyAPIError } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) async function readEvent(requestId: string) { for (let attempt = 1; ; attempt++) { try { return await fingerly.events.get(requestId) } catch (error) { const status = error instanceof FingerlyAPIError ? error.status : undefined if (status === 404 && attempt < 4) await sleep(attempt * 250) // not readable yet else if (status === 404 || status === 422) return null else throw error } } } /** * The stored event behind a request ID, or null when there is nothing to * trust: no ID, an unknown ID, another action's tag, too old, or used before. */ export async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) { if (typeof requestId !== 'string' || requestId === '') return null const event = await readEvent(requestId) if (!event || event.tag !== tag) return null if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null if (!(await usedRequestIds.add(event.request_id))) return null // your store: true only the first time return event } /** Whether any of these signal groups fired. */ export const fired = (event: { triggers: Array<{ signal: string }> }, ...groups: string[]) => event.triggers.some((trigger) => groups.includes(trigger.signal)) ``` ```python Python # fingerly_server.py import os import time from datetime import datetime, timedelta, timezone from fingerly import Fingerly, FingerlyAPIError fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) def _read_event(request_id): for attempt in range(1, 5): try: return fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404 and attempt < 4: time.sleep(attempt * 0.25) # not readable yet elif error.status in (404, 422): return None else: raise def verified_event(request_id, tag, max_age=timedelta(minutes=2)): """The stored event behind a request ID, or None when there is nothing to trust.""" if not isinstance(request_id, str) or not request_id: return None event = _read_event(request_id) if event is None or event.tag != tag: return None if datetime.now(timezone.utc) - event.occurred_at > max_age: return None if not used_request_ids.add(event.request_id): # your store: True only the first time return None return event def fired(event, *groups): """Whether any of these signal groups fired.""" return any(trigger.signal in groups for trigger in event.triggers) ``` `verifiedEvent` returns nothing when the client could not identify the visitor at all. Treat that as missing evidence: the policies below add friction a real customer can pass, rather than refusing outright. ## Decide ```ts Node.js import { fired, verifiedEvent } from './fingerly.server' export async function signUpDecision(requestId: unknown) { const event = await verifiedEvent(requestId, 'signup') if (!event) return { action: 'verify' } const existing = await accounts.countByVisitor(event.visitor_id, { days: 30 }) if (existing >= 3) return { action: 'refuse', event } if (fired(event, 'device_farm', 'bot', 'android_emulator', 'ios_simulator', 'cloned_app')) { return { action: 'review', event } } if (existing >= 1 || event.suspect_level !== 'low') return { action: 'verify', event } if (fired(event, 'fingerprint_suppressed')) return { action: 'verify', event } return { action: 'allow', event } } export async function signUp(email: string, requestId: unknown) { const { action, event } = await signUpDecision(requestId) if (action === 'refuse') return { error: 'refused' } // Keep the device with the account, and hold it for review or verification. return accounts.create({ email, status: action, visitorId: event?.visitor_id, signupRequestId: event?.request_id }) } ``` ```python Python from fingerly_server import fired, verified_event def sign_up_decision(request_id): event = verified_event(request_id, "signup") if event is None: return "verify", None existing = accounts.count_by_visitor(event.visitor_id, days=30) if existing >= 3: return "refuse", event if fired(event, "device_farm", "bot", "android_emulator", "ios_simulator", "cloned_app"): return "review", event if existing >= 1 or event.suspect_level != "low": return "verify", event if fired(event, "fingerprint_suppressed"): return "verify", event return "allow", event def sign_up(email, request_id): action, event = sign_up_decision(request_id) if action == "refuse": return {"error": "refused"} # Keep the device with the account, and hold it for review or verification. return accounts.create( email=email, status=action, visitor_id=event.visitor_id if event else None, signup_request_id=event.request_id if event else None, ) ``` ## A starting policy | Situation | Action | | --- | --- | | The device created 3 or more accounts in 30 days | Refuse, or create the account without its free benefits. | | `device_farm`, `bot`, `android_emulator`, `ios_simulator` or `cloned_app` fired | Create the account on hold for review. | | The device already has an account, the level is above `low`, or the request is unscored | Ask for verification, such as a phone number, before granting benefits. | | `fingerprint_suppressed` fired, or no usable identification | Ask for verification. The device cannot be counted. | | Otherwise | Allow. | Pick the limit that fits your product. A family sharing a tablet may reasonably create two accounts; a device creating twenty is not a household. ## Signals that matter here | Group | Why it matters for sign-ups | | --- | --- | | `device_farm` | Devices that look mass-provisioned or freshly reset, or share traits with many devices at once. | | `bot` | Sign-up forms filled by scripts. | | `android_emulator`, `ios_simulator`, `virtual_machine` | Accounts created in bulk from virtual devices. | | `cloned_app` | Several copies of your app on one phone, one per account. | | `incognito_mode`, `privacy_settings` | Weak on their own: many real customers browse privately. Useful in combination. | ## Link existing accounts Once `visitor_id` is stored with each account, accounts that share a device are one query away. Use it when you ban an account, to review its siblings, and when you investigate abuse after the fact. > **Note:** A visitor ID identifies a device, not a person. Households, shared computers and public terminals put several real people behind one visitor ID. Prefer verification and review to outright refusal. ## Roll it out - **Observe first.** Run the check and log the decision it would have made, next to what actually happened, for a week or two. - **Tune.** Look at the sessions the policy would have stopped in **Identification > Events**, and adjust [risk weights](https://docs.fingerly.io/docs/risk-weights) and the thresholds in your own code until they match what you see. - **Enforce gradually.** Turn on the friction a real customer can pass before the outright blocks. - [Promotion abuse](https://docs.fingerly.io/docs/use-cases/promotion-abuse): Stop sign-up bonuses and trials being claimed twice. - [Visitor identification](https://docs.fingerly.io/docs/visitor-identification): How stable a visitor ID is. --- # Promotion abuse > Enforce one redemption per device for coupons, referral rewards and free trials, however many accounts or email addresses the device uses. A promotion meant for one customer, redeemed by one person many times: a first-order discount on ten new accounts, a referral reward for referring yourself, a free trial started again every month. Limits per account or per email address do not help, because accounts and addresses are free. A limit per device does. ## Where to identify | Moment | Tag | Why | | --- | --- | --- | | Applying a coupon or promotion code | `promo:` | The tag binds the identification to one promotion. | | Claiming a referral reward | `referral:` | Compare the device with the referrer's. | | Starting a free trial | `trial-start` | One trial per device. | ## Identify in the client ```ts JavaScript import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…' }) async function submit() { let requestId: string | undefined try { ({ requestId } = await fingerly.identify({ tag: 'promo:' + code })) } catch { // Carry on: your server treats a missing request ID as missing evidence. } await fetch('/api/promotions/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, requestId }) }) } ``` ```swift Swift let requestId = try? await fingerly.identify(tag: "promo:\(code)").requestId try await api.redeem(code: code, requestId: requestId) ``` ```kotlin Kotlin val requestId = runCatching { fingerly.identify(tag = "promo:$code").requestId }.getOrNull() api.redeem(code, requestId) ``` ## Read the event on your server Every recipe starts the same way: read the stored event with a secret key, and refuse to trust it unless it belongs to this action, is recent, and has not been used before. Fingerly does not stop a request ID from being read twice, so the one-time check is yours: any store with an atomic "add if absent" works, such as Redis `SET` with `NX` and a ten-minute expiry. ```ts Node.js // fingerly.server.ts import { load, FingerlyAPIError } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) async function readEvent(requestId: string) { for (let attempt = 1; ; attempt++) { try { return await fingerly.events.get(requestId) } catch (error) { const status = error instanceof FingerlyAPIError ? error.status : undefined if (status === 404 && attempt < 4) await sleep(attempt * 250) // not readable yet else if (status === 404 || status === 422) return null else throw error } } } /** * The stored event behind a request ID, or null when there is nothing to * trust: no ID, an unknown ID, another action's tag, too old, or used before. */ export async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) { if (typeof requestId !== 'string' || requestId === '') return null const event = await readEvent(requestId) if (!event || event.tag !== tag) return null if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null if (!(await usedRequestIds.add(event.request_id))) return null // your store: true only the first time return event } /** Whether any of these signal groups fired. */ export const fired = (event: { triggers: Array<{ signal: string }> }, ...groups: string[]) => event.triggers.some((trigger) => groups.includes(trigger.signal)) ``` ```python Python # fingerly_server.py import os import time from datetime import datetime, timedelta, timezone from fingerly import Fingerly, FingerlyAPIError fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) def _read_event(request_id): for attempt in range(1, 5): try: return fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404 and attempt < 4: time.sleep(attempt * 0.25) # not readable yet elif error.status in (404, 422): return None else: raise def verified_event(request_id, tag, max_age=timedelta(minutes=2)): """The stored event behind a request ID, or None when there is nothing to trust.""" if not isinstance(request_id, str) or not request_id: return None event = _read_event(request_id) if event is None or event.tag != tag: return None if datetime.now(timezone.utc) - event.occurred_at > max_age: return None if not used_request_ids.add(event.request_id): # your store: True only the first time return None return event def fired(event, *groups): """Whether any of these signal groups fired.""" return any(trigger.signal in groups for trigger in event.triggers) ``` `verifiedEvent` returns nothing when the client could not identify the visitor at all. Treat that as missing evidence: the policies below add friction a real customer can pass, rather than refusing outright. ## Decide Enforce the limit in your database with a unique index on the promotion and the visitor ID, so two redemptions racing each other cannot both succeed. ```ts Node.js import { fired, verifiedEvent } from './fingerly.server' export async function redeem(accountId: string, code: string, requestId: unknown) { const event = await verifiedEvent(requestId, 'promo:' + code) // A promotion is optional: without evidence, ask for more before applying it. if (!event || fired(event, 'fingerprint_suppressed')) return 'verify' if (event.suspect_level === 'high') return 'refuse' // unique index on (code, visitor_id) const inserted = await redemptions.insertIfAbsent({ code, visitorId: event.visitor_id, accountId }) return inserted ? 'apply' : 'already-redeemed' } export async function isSelfReferral(referrerId: string, requestId: unknown) { const event = await verifiedEvent(requestId, 'referral:' + referrerId) if (!event) return true // no evidence, no reward return accounts.hasUsedDevice(referrerId, event.visitor_id) } ``` ```python Python from fingerly_server import fired, verified_event def redeem(account_id, code, request_id): event = verified_event(request_id, f"promo:{code}") # A promotion is optional: without evidence, ask for more before applying it. if event is None or fired(event, "fingerprint_suppressed"): return "verify" if event.suspect_level == "high": return "refuse" # unique index on (code, visitor_id) inserted = redemptions.insert_if_absent(code=code, visitor_id=event.visitor_id, account_id=account_id) return "apply" if inserted else "already-redeemed" def is_self_referral(referrer_id, request_id): event = verified_event(request_id, f"referral:{referrer_id}") if event is None: return True # no evidence, no reward return accounts.has_used_device(referrer_id, event.visitor_id) ``` ## A starting policy | Situation | Action | | --- | --- | | The device already redeemed this promotion | Refuse the discount, and say it has already been used on this device. | | A referral where the new customer's device has been used by the referrer | Create the account, but pay no reward. | | `high` level | Refuse the promotion. | | `fingerprint_suppressed` fired, or no usable identification | Ask for verification, such as a phone number, before applying it. | | Otherwise | Apply it. | ## Signals that matter here | Group | Why it matters for promotions | | --- | --- | | `fingerprint_suppressed` | The device hides enough to get a new visitor ID every time, which would defeat a per-device limit. | | `device_farm`, `android_emulator`, `ios_simulator`, `cloned_app` | Many "devices" that are really one person's setup. | | `bot` | Redemptions scripted at scale. | | `residential_proxy`, `vpn` | Used to make repeated sign-ups look like different households. | > **Tip:** Tell customers the limit is per device in the promotion's terms. A real customer who is refused then knows why. ## Roll it out - **Observe first.** Run the check and log the decision it would have made, next to what actually happened, for a week or two. - **Tune.** Look at the sessions the policy would have stopped in **Identification > Events**, and adjust [risk weights](https://docs.fingerly.io/docs/risk-weights) and the thresholds in your own code until they match what you see. - **Enforce gradually.** Turn on the friction a real customer can pass before the outright blocks. - [Sign-up abuse](https://docs.fingerly.io/docs/use-cases/sign-up-abuse): Stop the accounts before they redeem. - [Payment fraud](https://docs.fingerly.io/docs/use-cases/payment-fraud): Protect the order the promotion applies to. --- # Payment fraud > Score every checkout, step up risky payments, hold high-risk orders for review, stop card testing per device, and keep evidence for chargeback disputes. Payment fraud comes in two shapes. **Stolen cards** are used to buy goods the thief resells, and the real cardholder's chargeback arrives weeks later. **Card testing** tries long lists of stolen card numbers with small payments to find the ones that still work. Both cost you the goods, the fees, and your standing with your payment provider. ## Where to identify | Moment | Tag | Why | | --- | --- | --- | | Submitting the payment | `checkout:` | The tag ties the identification to one order. | | Adding or updating a saved card | `add-card` | Card testing often happens here rather than at checkout. | | Buying gift cards or digital goods | `checkout:` | Instantly resellable, so a favourite of stolen cards. | ## Identify in the client ```ts JavaScript import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…' }) async function submit() { let requestId: string | undefined try { ({ requestId } = await fingerly.identify({ tag: 'checkout:' + orderId })) } catch { // Carry on: your server treats a missing request ID as missing evidence. } await fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orderId, requestId }) }) } ``` ```swift Swift let requestId = try? await fingerly.identify(tag: "checkout:\(orderId)").requestId try await api.pay(orderId: orderId, requestId: requestId) ``` ```kotlin Kotlin val requestId = runCatching { fingerly.identify(tag = "checkout:$orderId").requestId }.getOrNull() api.pay(orderId, requestId) ``` ## Read the event on your server Every recipe starts the same way: read the stored event with a secret key, and refuse to trust it unless it belongs to this action, is recent, and has not been used before. Fingerly does not stop a request ID from being read twice, so the one-time check is yours: any store with an atomic "add if absent" works, such as Redis `SET` with `NX` and a ten-minute expiry. ```ts Node.js // fingerly.server.ts import { load, FingerlyAPIError } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) async function readEvent(requestId: string) { for (let attempt = 1; ; attempt++) { try { return await fingerly.events.get(requestId) } catch (error) { const status = error instanceof FingerlyAPIError ? error.status : undefined if (status === 404 && attempt < 4) await sleep(attempt * 250) // not readable yet else if (status === 404 || status === 422) return null else throw error } } } /** * The stored event behind a request ID, or null when there is nothing to * trust: no ID, an unknown ID, another action's tag, too old, or used before. */ export async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) { if (typeof requestId !== 'string' || requestId === '') return null const event = await readEvent(requestId) if (!event || event.tag !== tag) return null if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null if (!(await usedRequestIds.add(event.request_id))) return null // your store: true only the first time return event } /** Whether any of these signal groups fired. */ export const fired = (event: { triggers: Array<{ signal: string }> }, ...groups: string[]) => event.triggers.some((trigger) => groups.includes(trigger.signal)) ``` ```python Python # fingerly_server.py import os import time from datetime import datetime, timedelta, timezone from fingerly import Fingerly, FingerlyAPIError fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) def _read_event(request_id): for attempt in range(1, 5): try: return fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404 and attempt < 4: time.sleep(attempt * 0.25) # not readable yet elif error.status in (404, 422): return None else: raise def verified_event(request_id, tag, max_age=timedelta(minutes=2)): """The stored event behind a request ID, or None when there is nothing to trust.""" if not isinstance(request_id, str) or not request_id: return None event = _read_event(request_id) if event is None or event.tag != tag: return None if datetime.now(timezone.utc) - event.occurred_at > max_age: return None if not used_request_ids.add(event.request_id): # your store: True only the first time return None return event def fired(event, *groups): """Whether any of these signal groups fired.""" return any(trigger.signal in groups for trigger in event.triggers) ``` `verifiedEvent` returns nothing when the client could not identify the visitor at all. Treat that as missing evidence: the policies below add friction a real customer can pass, rather than refusing outright. ## Decide ```ts Node.js import { verifiedEvent } from './fingerly.server' export async function checkoutDecision(order: Order, requestId: unknown) { const event = await verifiedEvent(requestId, 'checkout:' + order.id) if (!event) return 'authenticate' // 3-D Secure or your equivalent // Evidence for a dispute, kept with the order for as long as you keep orders. await orders.saveEvidence(order.id, { requestId: event.request_id, visitorId: event.visitor_id, ipAddress: event.ip_address, countryCode: event.country_code, suspectLevel: event.suspect_level, }) const cards = await payments.distinctCardsByVisitor(event.visitor_id, { hours: 24 }) if (cards >= 4) return 'refuse' // card testing if (event.suspect_level === 'high') return 'review' if (event.suspect_level !== 'low') return 'authenticate' // medium, or not scored if (event.anonymity_network || event.country_code !== order.billingCountry) return 'authenticate' return 'capture' } ``` ```python Python from fingerly_server import verified_event def checkout_decision(order, request_id): event = verified_event(request_id, f"checkout:{order.id}") if event is None: return "authenticate" # 3-D Secure or your equivalent # Evidence for a dispute, kept with the order for as long as you keep orders. orders.save_evidence( order.id, request_id=event.request_id, visitor_id=event.visitor_id, ip_address=event.ip_address, country_code=event.country_code, suspect_level=event.suspect_level, ) cards = payments.distinct_cards_by_visitor(event.visitor_id, hours=24) if cards >= 4: return "refuse" # card testing if event.suspect_level == "high": return "review" if event.suspect_level != "low": return "authenticate" # medium, or not scored if event.anonymity_network or event.country_code != order.billing_country: return "authenticate" return "capture" ``` ## A starting policy | Situation | Action | | --- | --- | | 4 or more different cards from one visitor in 24 hours | Refuse, and stop taking payments from that visitor for a day. | | `high` level | Hold the order for manual review before fulfilment. | | `medium`, unscored, or no usable identification | Ask the payment provider to authenticate the cardholder, such as with 3-D Secure. | | A Tor, VPN, proxy or hosting network, or a network country different from the billing country | Authenticate the cardholder. | | Otherwise | Capture. | ## Signals that matter here | Group | Why it matters for payments | | --- | --- | | `tor`, `datacenter_proxy`, `residential_proxy`, `vpn` | Used to match the stolen card's country and hide the buyer. | | `location_spoofing` | The device pretends to be near the cardholder. | | `bot`, `high_activity` | Card testing is scripted, and one device makes far more payments than a customer would. | | `browser_tampering`, `virtual_machine`, `device_farm` | Tooling that disguises the device between attempts. | ## Keep evidence for chargebacks Chargebacks arrive long after the 30 days Fingerly keeps events, so keep what you need at the time of the order: the request ID, the visitor ID, the address and country, and the level. To keep the complete events, subscribe to the [`identification.completed`](https://docs.fingerly.io/reference/webhooks/identification-completed) webhook or export them with [List events](https://docs.fingerly.io/reference/list-events). See [data retention](https://docs.fingerly.io/docs/data-retention#keeping-your-own-copy). A visitor ID that placed earlier, undisputed orders for the same customer is useful evidence that the disputed order came from the customer too. ## Roll it out - **Observe first.** Run the check and log the decision it would have made, next to what actually happened, for a week or two. - **Tune.** Look at the sessions the policy would have stopped in **Identification > Events**, and adjust [risk weights](https://docs.fingerly.io/docs/risk-weights) and the thresholds in your own code until they match what you see. - **Enforce gradually.** Turn on the friction a real customer can pass before the outright blocks. - [Account takeover](https://docs.fingerly.io/docs/use-cases/account-takeover): Protect saved cards behind the login. - [Webhooks](https://docs.fingerly.io/docs/webhooks): Keep your own copy of every identification. --- # Test your integration > Mock the SDK in unit and component tests, run end-to-end tests with development keys in automated browsers, and check a policy change before it reaches production. An integration has three parts worth testing: the client code that identifies and sends the request ID, the server code that reads the event and decides, and the policy itself. Each is tested differently. | What | Test with | Network | | --- | --- | --- | | Client code, in unit tests | A fake transport | None | | Components, in React, Vue, Svelte or Angular tests | A mocked identify endpoint | None | | The whole flow, end to end | A development key and a real browser | Real | | Your server's decisions | Event fixtures | None | | Weights and the threshold | Recent events, replayed | Server API | ## Use development keys - Development and staging keys run the same detection as production and are never billed. Use them for every test that talks to Fingerly. - Add each origin your tests run on to the public key, exactly: `http://localhost:3000` does not allow `http://127.0.0.1:3000` or `http://localhost:4173`. - Events from development keys stay out of production dashboards, webhooks and secret-key reads. - Visitor IDs are not separated by environment. A device you test with in development is already a returning visitor when it reaches production in the same organization. > **Note:** There is no test mode, allowlist or header that turns detection off. Test traffic is scored like any other. ## Unit tests ### Collect without sending `identify({ submit: false })` collects signals and computes local verdicts without contacting Fingerly. It is enough for code that only reads verdicts. `requestId` and `visitorId` are empty, `suspectScore` and `suspectLevel` are `null`, and consent is still required. ### Replace the transport To test code that needs a request ID, give `load()` a `transport` that answers instead of the API. The client is the real one, so consent, results and errors behave as they do in production. `sources: []` skips collection, which keeps tests fast and independent of the test environment. ```ts test/fingerly.ts import { load } from '@fingerly/web-js' /** A real client whose identify requests are answered by the test. */ export function fakeFingerly(answer: Record = {}) { return load({ apiKey: 'fly_pk_us_development_…', sources: [], transport: { submit: async () => ({ request_id: '01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4', visitor_id: 'X9pL2mRc7KvT4bQw8NdF', visitor_is_new: false, identifiable: true, visitor_confidence: 100, state: 'enriched', suspect_score: 0, suspect_level: 'low', triggers: [], ...answer, }), }, }) } ``` ```ts login.test.ts import { expect, it, vi } from 'vitest' import { load } from '@fingerly/web-js' import { fakeFingerly } from './test/fingerly' import { submitLogin } from '../src/login' it('sends the request ID with the login', async () => { const post = vi.fn() await submitLogin(await fakeFingerly(), post, { email: 'a@example.com', password: 'secret' }) expect(post).toHaveBeenCalledWith(expect.objectContaining({ requestId: '01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4' })) }) it('logs in without a request ID when Fingerly cannot be reached', async () => { const fingerly = await load({ apiKey: 'fly_pk_us_development_…', sources: [], transport: { submit: () => Promise.reject(new Error('offline')) }, }) const post = vi.fn() await submitLogin(fingerly, post, { email: 'a@example.com', password: 'secret' }) expect(post).toHaveBeenCalledWith(expect.objectContaining({ requestId: undefined })) }) ``` A transport answer needs only `request_id`; every other field is optional, and the client turns the answer into the usual camel-case result. Without a `submitSupplement` method, `result.deferred` resolves as `skipped` with the reason `unsupported-transport`. ## Component tests The framework SDKs create their client from options, so they cannot take a transport. Answer their requests at the network instead. [Mock Service Worker](https://mswjs.io) intercepts the SDK's `fetch` in Vitest and Jest, for every framework. ```ts test/setup.ts import { afterAll, afterEach, beforeAll } from 'vitest' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' export const fingerlyApi = setupServer( http.post('*/api/v1/identify', () => HttpResponse.json({ request_id: '01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4', visitor_id: 'X9pL2mRc7KvT4bQw8NdF', visitor_is_new: false, identifiable: true, visitor_confidence: 100, state: 'enriched', suspect_score: 0, suspect_level: 'low', triggers: [], }), ), ) beforeAll(() => fingerlyApi.listen()) afterEach(() => fingerlyApi.resetHandlers()) afterAll(() => fingerlyApi.close()) ``` ```tsx Checkout.test.tsx import { http, HttpResponse } from 'msw' import { fingerlyApi } from './test/setup' it('asks for a second factor when the answer is high', async () => { fingerlyApi.use( http.post('*/api/v1/identify', () => HttpResponse.json({ request_id: '01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4', visitor_id: 'X9pL2mRc7KvT4bQw8NdF', suspect_score: 37, suspect_level: 'high', triggers: [] }), ), ) // render inside and assert }) ``` > **Tip:** Leave `deferred_token` out of the fixture. The SDK then sends no deferred report, so there is nothing else to intercept. > **Warning:** In jsdom and happy-dom the SDK runs as it would in a browser. Without a mock, `identify()` sends a real request, which fails unless the test's origin is allowed on the key. ## End-to-end tests Run end-to-end tests against real identification, with a development key. That is the only way to test that your origins, proxy, consent flow and server read fit together. - **Expect automation.** Playwright, Selenium, Puppeteer, Cypress and headless browsers are automated browsers, and Fingerly detects them as such: expect the `bot` group in the triggers, and a score at `medium` or `high`. The same holds for iOS simulators and Android emulators, and for debug builds with developer tooling attached. - **Do not assert on the score or the level.** Assert that a request ID reached your server, that your server could read the event, and that the flow finished. - **Make enforcement configurable.** In test environments, have your server log the decision it would make instead of acting on it, and test the enforced paths with event fixtures. ```ts Playwright import { expect, test } from '@playwright/test' test('login carries a readable identification', async ({ page }) => { const identified = page.waitForResponse('**/api/v1/identify') await page.goto('/login') await page.getByLabel('Email').fill('e2e@example.com') await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!) await page.getByRole('button', { name: 'Log in' }).click() expect((await identified).status()).toBe(200) await expect(page).toHaveURL('/account') }) test('login still works when identification is blocked', async ({ page }) => { await page.route('**/api/v1/identify', (route) => route.abort('blockedbyclient')) await page.goto('/login') // … the visitor must still be able to continue, with more friction at most }) ``` > **Tip:** Blocking the identify request, as in the second test, is how you check that a content blocker degrades to missing evidence rather than a broken page. ## Server tests Test your decisions without Fingerly by reading events through one function of your own and replacing it in tests. Start from a real event: copy one from **Identification > Events**, or read one with a development secret key, and save it as a fixture. ```ts decide.test.ts import { expect, it, vi } from 'vitest' import event from './fixtures/event.json' import * as fingerly from '../src/fingerly.server' import { decide } from '../src/checkout' vi.mock('../src/fingerly.server') it('refuses an identification made for another order', async () => { vi.mocked(fingerly.readEvent).mockResolvedValue({ ...event, tag: 'checkout:1' }) expect(await decide('8412', event.request_id)).toBe('refuse') }) ``` | Case | Fixture change | What your code should do | | --- | --- | --- | | No request ID | None: pass `undefined` | Continue with missing evidence. | | Unknown request ID | The read returns nothing, as for a `404` | Treat as missing evidence. | | Another action's identification | `tag` changed | Refuse to trust it. | | An old identification | `occurred_at` minutes in the past | Refuse to trust it. | | A reused request ID | The same fixture twice | Trust it once. | | Not scored | `suspect_score: null`, no `suspect_level` | Your fallback, not `low`. | | Each level | `suspect_level` set to `low`, `medium`, `high` | Each action in your policy. | | A decisive signal | A group added to `triggers` | The rule for that signal. | ## Test a policy change Your own policy, the code that turns an event into an action, is ordinary code: test it on staging, where keys run the same detection for free. Risk weights and the threshold you set in the dashboard apply to your organization in every environment at once. There are two ways to see a change before it reaches production. - **Give staging keys their own profile.** A key's own weights override the organization's for that key alone. Save them on your staging keys with the [management API](https://docs.fingerly.io/reference/management/risk-weights#save-an-sdk-key-s-weights), run staging traffic, then copy the profile to the organization. - **Replay recent events.** Read recent events and work out how many would change level under the new threshold, as below. ```ts replay-threshold.ts import { load } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) // a production key const proposed = 25 // How Fingerly derives a level from a score and a threshold. const levelFor = (score: number, threshold: number) => score <= 0 ? 'low' : score >= threshold ? 'high' : score >= Math.floor(threshold / 2) ? 'medium' : 'low' const from = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) const changes: Record = {} for (let page = 1; ; page++) { const { rows, page_size } = await fingerly.events.list({ from, page, limit: 200 }) for (const event of rows) { if (event.suspect_score === null) continue const next = levelFor(event.suspect_score, proposed) if (next !== event.suspect_level) { const key = event.suspect_level + ' -> ' + next changes[key] = (changes[key] ?? 0) + 1 } } if (rows.length < page_size) break } console.table(changes) ``` - A listing reaches back at most 10,000 events. For busier traffic, replay several shorter windows. - For a weight change, add the difference to the score of events where that group fired. Groups with several signals make this an estimate. - After the change, watch **Insights > Suspect score trends**, and the volume of `visitor.suspect` webhooks. - [Troubleshooting](https://docs.fingerly.io/docs/troubleshooting): When a test fails for a reason outside your code. - [Plan your integration](https://docs.fingerly.io/docs/planning-your-integration#roll-out-a-policy): Roll out a policy in stages. --- # Troubleshooting > Fix the problems integrations run into: 401 and CORS errors, content blockers, events that cannot be read, visitor IDs that change, high scores and slow identification. Find the symptom, then work down its causes, most likely first. If you are still stuck, [ask for help](#get-help). ## Identification fails ### 401 Unauthorized in the browser Fingerly answers every authentication failure with the same `401`, so the response does not say which of these it was. | Cause | How to check | Fix | | --- | --- | --- | | The page's origin is not in the key's allowed origins | Run `location.origin` in the browser console and compare it, character for character, with the key's origins in **Integration > SDK keys**. | Add the exact origin. `https://example.com` does not cover `https://www.example.com`, and ports count. | | The key has no allowed origins | The key's row lists none. | Add at least one. A public key with none refuses every browser request. | | The key was revoked or has expired | The key's status in **SDK keys**. | Deploy an active key. | | A secret key is in front-end code | The key starts with `fly_sk_`. | Use a public key, `fly_pk_`, in browsers and apps. Revoke the exposed secret key. | | A proxy forwards a mismatched key | The proxy key and the public key differ in organization, environment or region. | Issue a proxy key for the same environment and region. | > **Tip:** Subscribe a webhook endpoint to [`identification.refused`](https://docs.fingerly.io/reference/webhooks/identification-refused). Its `reason` names the cause: `origin_not_allowed`, `revoked_key` or `expired_key`. A key Fingerly does not recognise at all, or one sent to another region's API, cannot be attributed to you and sends no webhook. ### CORS errors in the console Fingerly answers the browser's preflight for every origin, so a CORS error is rarely a Fingerly setting. - **The request never reached Fingerly.** An extension, a corporate proxy or a captive portal answered instead. Check the Network panel for the request's real response. - **Your own proxy does not answer preflights.** It must answer `OPTIONS` and allow the `Content-Type`, `x-api-key` and `Idempotency-Key` headers. See [proxy integrations](https://docs.fingerly.io/docs/proxy-integrations#build-your-own-proxy). - **Browser code calls the server API.** `GET /events` never answers browsers. Read events from your backend with a secret key. Response headers such as `RateLimit-Remaining` and `Fingerly-Balance-Micros` are readable from browser JavaScript, so they need no change. ### Requests blocked by content blockers The request is missing from the Network panel, or fails with `net::ERR_BLOCKED_BY_CLIENT`, and `identify()` rejects with a `TransportError` that has no `status`. - Serve the SDK's requests from a path on your own site with a [proxy integration](https://docs.fingerly.io/docs/proxy-integrations). Choose a neutral path. - Keep your server working without a request ID. A visitor whose blocker stops identification should get more friction at most, never a broken page. ### 402 Payment Required `no_credit`: the balance is used up; add funds or turn on auto top-up. `billing_blocked`: the organization is not accepting traffic; resolve it in **Settings > Billing**. Development and staging keys keep working either way. See [billing](https://docs.fingerly.io/docs/billing). ### 429 Too Many Requests Your organization is over its [rate limit](https://docs.fingerly.io/docs/rate-limits), across all keys and environments. The SDKs already retry. Identify at meaningful moments rather than on every page view, and look for a load test or an attack sending identifications. ### Other errors from the SDK | Error | Cause | Fix | | --- | --- | --- | | `ConsentError`, code `consent_required` | The SDK was loaded with `consent` `pending` or `denied`, and consent was never granted. | Call `setConsent('granted')` when your consent tool records consent. See [consent tools](https://docs.fingerly.io/docs/consent-tools). | | `FingerlyServerError` | `identify()` ran during server rendering. | Call it from an effect or an event handler, or use `immediate`, which waits for the browser. | | `load()` throws | A configuration mistake: an empty `apiKey`, both `endpoint` and `endpoints`, or an endpoint that is neither a URL nor a path. | Fix the option. `load()` throws synchronously, so call it inside `try`. | | `TransportError` with `status` `404` or `405` | An `endpoints` path with no proxy behind it. | Deploy the proxy, or remove `endpoints`. | | React Native: native module missing | The app was not rebuilt after installing, or runs in Expo Go. | Rebuild the app, in a development build. | | Flutter: missing plugin | Hot reload after installing. | Run `flutter clean` and rebuild. | ## Reading events ### 404 event_not_found | Cause | Fix | | --- | --- | | The event is not readable yet. It usually is within a few seconds. | Retry a `404` a few times over a few seconds before giving up. | | The secret key is from another environment than the public key that identified. | A development identification is read with a development secret key, a production one with a production key. | | The identification is older than 30 days. | Keep your own copy. See [data retention](https://docs.fingerly.io/docs/data-retention#keeping-your-own-copy). | | The client never submitted. `submit: false` returns an empty request ID. | Identify with `submit` left on. | ### 401 or 422 when reading - `401`: the key is not a secret key, or the request was sent from a browser with an `Origin` header. Read events from your backend. - `422 invalid_request_id`: the value is not a Fingerly request ID. Pass `request_id` exactly as the SDK returned it, and check your frontend actually sent it. ### The tag does not match - A tag passed to `identify({ tag })` overrides the tag set on a framework hook. - A tag function is read when the request is sent. If the value it reads, such as an order ID, is set after that, the tag is stale. - Framework SDKs share one identification per page. If one page protects two actions, call `identify({ force: true })` or `refresh()` for the second. ## Visitor IDs ### The visitor ID changes between visits | Cause | How to tell | | --- | --- | | The browser or device gives too little to identify anyone, such as a hardened privacy browser. It gets a new visitor ID every time. | `identifiable` is `false`, and `fingerprint_suppressed` is in the triggers. | | The device changed too much to be recognised, for example after a reset. | `visitor_confidence` is `0` and `visitor_is_new` is `true`. Smaller changes are recognised with a confidence from `85` to `99`. | | The device was not seen for 180 days. | The previous visit is older than that. | | The two visits were identified by different organizations, such as one per region. | Visitor IDs are scoped to an organization, so each organization has its own. | ### A device is returning the first time it reaches production Visitor IDs belong to the organization, not to an environment. A device that was identified with a development or staging key is already known when it is identified with a production key. ### Different people share a visitor ID A visitor ID identifies a device, not a person, so everyone using a shared device shares one. Devices set up identically, such as a fleet of managed laptops, can also be hard to tell apart. Combine the visitor ID with your own account data before acting on it alone. ## Scores ### Every test session scores high - Automated browsers are detected as automation. See [end-to-end tests](https://docs.fingerly.io/docs/testing#end-to-end-tests). - VPNs, Tor and datacenter networks on development machines raise network signals. - The iOS Simulator, Android emulators, rooted test devices and debug builds with developer tooling attached raise device signals with heavy weights. Test on a physical device over an ordinary connection when you need a realistic score. ### The score is missing `state` is `unavailable`: the network lookup could not run, so the request was answered but not scored, and not billed. This is not a score of `0`. Your server needs a rule for it. See [suspect score](https://docs.fingerly.io/docs/suspect-score#when-a-request-is-not-scored). ### A signal fires on real customers `vpn`, `incognito_mode` and `privacy_settings` are common among ordinary, privacy-minded people, which is why their default weights are low. If one pushes real customers over your threshold, lower its [weight](https://docs.fingerly.io/docs/risk-weights) rather than removing the check. ### high_activity never fires Behaviour signals learn your traffic first. Until an environment has at least 1,000 identifications and 7 days of history on a platform, they are reported without adding to the score. See [signals](https://docs.fingerly.io/docs/signals#behaviour-signals). ## Slow identification - **Load once, early.** Call `load()` when your application starts. Loading collects and sends nothing. - **Identify before the click.** For a form that has to feel instant, identify when the visitor starts filling it in, and send the request ID with the submission. Allow for the time the form takes in your server's age check. - **Collection is capped.** The initial collection in browsers stops at 300 ms, and the slower signals are collected after the answer, without delaying it. What remains is the network. - **Retries add up.** On a failing connection, the SDK makes up to three attempts of 5 seconds each. Pass an `AbortSignal` to stop sooner. - **On iOS and Android**, `identify(tiers: [.fast])` skips the slower collection tier for screens that cannot wait. ## Webhooks ### Signature verification fails - The signature is computed over the raw body. Verify the bytes you received, before any JSON parsing. - Use the whole signing secret as the key, `whsec_` included, and the secret of this endpoint. - Your server's clock is more than five minutes out. Synchronise it with NTP. - During a secret rotation the header holds two signatures separated by a comma. Accept the delivery if either verifies. ### No deliveries arrive - The endpoint listens to the wrong environment. **Live** receives production traffic only; **Test** receives development and staging. - The endpoint is paused, or not subscribed to that event type. `billing.status_changed` is for live endpoints only. - The URL redirects. Redirects are not followed; register the final URL. - The URL is not public HTTPS. Use a tunnel for a local server. Open the endpoint in **Integration > Webhooks** for its delivery history, and use **Send test event** to check the URL and your verification in one step. See [webhooks](https://docs.fingerly.io/docs/webhooks). ## Get help Email [support@fingerly.io](mailto:support@fingerly.io) with: - The `request_id` of an affected identification, or the `X-Request-Id` header of a failed response. - The key's prefix, such as `fly_pk_us_production_`. Never send a whole secret key. - The SDK and its version, and the browser or device. - When it happened, with the time zone. --- # Proxy integrations > Route the browser SDK through a path on your own domain so content blockers do not stop identification, without losing the visitor's real network signals. Content blockers and privacy extensions often refuse requests to third-party APIs. When they refuse Fingerly's, the visitor is simply not identified. A proxy integration sends the SDK's requests to a path on your own site instead, where they look like the rest of your traffic. ## Why a Fingerly proxy is different A naive reverse proxy would make every visitor arrive from your server's address, and every network signal would describe your server rather than the visitor. A Fingerly proxy authenticates with a **proxy key**, and only then may it tell Fingerly the visitor's real address, origin and user agent. A public key alone can never do that, so nobody can spoof those details with your public key. ## Options | Option | Runs in | Best for | | --- | --- | --- | | [Cloudflare Worker](https://docs.fingerly.io/docs/sdks/cloudflare-worker) | Your Cloudflare account | Sites already on Cloudflare. No application changes. | | [Node.js `createProxy`](https://docs.fingerly.io/docs/sdks/node#serve-the-browser-sdk-from-your-domain) | Your application | Next.js, Remix, Hono, SvelteKit, Nuxt and any runtime with web `Request` and `Response`. | | Your own proxy | Anywhere | Other stacks. Follow the contract below. | ## Proxy keys Proxy keys look like `fly_px_us_production_…`. Owners and admins issue them for one environment in **Integration > Proxy keys**, and each key is shown once, when it is issued. Only owners and admins can see and revoke them. A proxy key only forwards client requests: it cannot read events, and it must match the public key's organization, environment and region. > **Warning:** Store the proxy key as a server secret. Never ship it to browsers. ## Point the SDK at your path ```ts fingerly.ts import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…', endpoints: '/metrics', // your proxy's path fallbackToDefaultEndpoint: true, // optional: try Fingerly directly if the proxy fails }) ``` The SDK appends `/api/v1/identify` to the path, and sends deferred reports to `/api/v1/events/{request_id}/supplement` under it. Omit `fallbackToDefaultEndpoint` to keep every request first-party. > **Tip:** Choose a neutral path. Blockers look for words such as `fingerprint`, `tracking` and `fingerly`. ## Build your own proxy Forward only the two client routes, as `POST`, to your region's API, and replace the visitor headers with values you resolved yourself. | Incoming path | Forward to | | --- | --- | | `{prefix}/api/v1/identify` | `https://us.api.fingerly.io/api/v1/identify` | | `{prefix}/api/v1/events/{request_id}/supplement` | `https://us.api.fingerly.io/api/v1/events/{request_id}/supplement` | | Header to send | Value | | --- | --- | | `x-api-key` | The incoming `x-api-key` (the public key), unchanged. | | `x-fingerly-proxy-key` | Your proxy key. | | `x-fingerly-client-ip` | The visitor's IP address, from your load balancer or CDN. | | `x-fingerly-origin` | The incoming `Origin`. | | `x-fingerly-user-agent` | The incoming `User-Agent`. | | `Idempotency-Key` | The incoming value, when present. | | `Content-Type` | `application/json`. | - Do not forward cookies or `Authorization` headers, and strip `Set-Cookie` from responses. - Refuse other paths and methods, and cap request bodies at 1 MiB. - Do not follow redirects from the upstream. - Answer CORS preflights if the page and the proxy are on different origins. > **Warning:** Resolve the visitor's IP from infrastructure you control, such as `CF-Connecting-IP` behind Cloudflare. Never trust the left-most `X-Forwarded-For` value, which the visitor can write. ## Data residency A proxy never changes where data goes. The region is part of both keys, and a request forwarded to another region's API is refused. --- # Content Security Policy > The Content Security Policy directives the browser SDK needs: which hosts to allow, and what it never requires. If your site sends a `Content-Security-Policy` header, allow the SDK to reach Fingerly. The SDK needs very little. ## Directives | Directive | Allow | Why | | --- | --- | --- | | `connect-src` | `https://us.api.fingerly.io` | Identify requests and deferred reports. | | `script-src` | The origin you serve `fingerly.global.js` from | Only if you use the script-tag build. A bundled import needs nothing extra. | ```http Header Content-Security-Policy: default-src 'self'; connect-src 'self' https://us.api.fingerly.io; script-src 'self' ``` With a [proxy integration](https://docs.fingerly.io/docs/proxy-integrations) on your own origin, `connect-src 'self'` is enough. ## What the SDK never needs - `unsafe-eval`. The SDK does not need it; under a strict policy it simply skips what it cannot do. - `worker-src`, `wasm-unsafe-eval` or `blob:`. The SDK uses no workers and no WebAssembly. - Frames from other origins. The SDK only creates temporary same-origin frames, and keeps working without them if your policy forbids frames. - Cookies. Requests are sent without credentials, and responses set none. ## CORS Browser requests carry the `x-api-key` header, so browsers send a preflight first. Fingerly answers it for every origin; access is then decided by your public key's allowed origins. > **Note:** The server API, `GET /events`, does not answer browsers at all. Read events from your backend. --- # Mobile app attestation > How Fingerly checks that a mobile report comes from your genuine app on a genuine device: hardware-backed attestation on Android, and integrity signals on iOS. A mobile app talks to Fingerly from a device you do not control. Attestation and integrity signals tell you whether the report came from your genuine app on a genuine, unmodified device. ## Android: hardware-backed attestation On Android 7.0 (API 24) and newer, the SDK uses the device's hardware-backed keystore to attest to a fresh challenge from Fingerly. The server verifies the result. There is nothing to configure. | Step | What happens | | --- | --- | | 1 | The SDK requests a [challenge](https://docs.fingerly.io/reference/attestation-challenge): 32 random bytes, valid for five minutes, usable once. | | 2 | The device keystore creates a temporary key attested to that challenge, and the SDK includes the attestation in its report. | | 3 | Fingerly verifies the attestation against Google's roots, checks it was made for your app and signing certificate, that the device's boot state is verified, and that the challenge is fresh and unused. | | 4 | A failed check raises the `tampering` signal, and an app not signed as expected raises `cloned_app`. | Because each challenge is consumed on use, a captured report cannot be replayed. > **Note:** Attestation needs no Google Play services, so it also works on devices without them. ## iOS: integrity signals On iOS, device integrity comes from the SDK's own checks, scored on the server: jailbroken devices, the Simulator, attached instrumentation, intercepted traffic, runtime tampering and developer tooling. Each is a [signal](https://docs.fingerly.io/docs/signals) with its own weight. ## Using the result ```ts Server async function checkIntegrity(requestId: string) { const event = await fingerly.events.get(requestId) const tampered = event.triggers.some((t) => t.signal === 'browser_tampering' || t.signal === 'cloned_app') if (tampered) return refuse('app integrity') return allow() } ``` > **Tip:** On events read with a secret key, triggers are grouped: `tampering` appears under `browser_tampering`, and `app_cloner` under `cloned_app`. See [signals](https://docs.fingerly.io/docs/signals). - [Android SDK](https://docs.fingerly.io/docs/sdks/android): Install and identify. - [iOS SDK](https://docs.fingerly.io/docs/sdks/ios): Install and identify. --- # API keys and environments > Create public and secret keys for development, staging and production, restrict public keys to your origins, and keep test traffic apart from real traffic. Keys identify your organization to Fingerly and decide what a request may do. Every key belongs to one **environment** and one **region**, and both are written into the key itself. ## Environments | Environment | Public key prefix | Billed | Use it for | | --- | --- | --- | --- | | Development | `fly_pk_us_development_` | No | Local development and CI. | | Staging | `fly_pk_us_staging_` | No | Pre-release testing with realistic traffic. | | Production | `fly_pk_us_production_` | Yes | Real visitors. | All three run exactly the same detection. What differs is billing, and everywhere results are read: - A secret key reads only events from its own environment. - Dashboards and the Events view filter by environment. - Webhook endpoints listen to **Live** (production) or **Test** (staging and development) traffic. - Usage counts non-production requests separately, as not billed. > **Tip:** Because the environment is part of the key, a development key shipped to production is visible in a code review. ## Kinds of key | Kind | Where it lives | What it does | | --- | --- | --- | | Public, `fly_pk_` | Web pages and apps | Identifies visitors. Accepted only from its allowed origins in browsers. | | Secret, `fly_sk_` | Your servers | Reads events. Refused from browsers. | | Proxy, `fly_px_` | Your proxy | Forwards identify requests with visitor details. See [proxy integrations](https://docs.fingerly.io/docs/proxy-integrations). | | Management, `fly_mk_` | Your automation | Manages keys, webhook endpoints and risk weights through the [management API](https://docs.fingerly.io/reference/management/overview). Belongs to no environment. Refused from browsers. | ## Create a key In the dashboard, open **Integration > SDK keys** and create a key. Choose its kind and environment, name it, and for a public key list its allowed origins. You can also set an expiry date. Proxy keys are issued separately, in **Integration > Proxy keys**. > **Warning:** A secret key is shown once, when it is created. Copy it into your secret manager straight away. Fingerly stores only a hash of each key and cannot show it again. Owners, admins and developers can create and revoke keys. To create keys from code, for example with each new staging environment, use the [management API](https://docs.fingerly.io/reference/management/sdk-keys). ## Management keys Management keys let automation manage your integration without a person signed in. Owners and admins issue them in **Integration > Management keys**, each acting with the `admin` or `developer` role. A management key is shown once, is refused from browsers, and cannot issue other management keys. See the [management API](https://docs.fingerly.io/reference/management/overview). ## Allowed origins A public key is accepted from a browser only when the page's origin exactly matches one of the key's allowed origins. ```text Allowed origins https://shop.example.com https://www.example.com http://localhost:3000 ``` - An origin is a scheme, a host and an optional port, with no path and no trailing slash. - Matching is exact: `https://example.com` does not allow `https://www.example.com`, and there are no wildcards. - A public key with no allowed origins refuses every browser request. - Native apps identify their platform instead of an origin, so mobile SDKs do not need one. You can change a public key's allowed origins at any time from its row in **SDK keys**. The change applies within a minute. ## Revoke a key Revoking a key refuses it immediately and cannot be undone. Requests made with it afterwards fail with `401`, and each sends an [`identification.refused`](https://docs.fingerly.io/reference/webhooks/identification-refused) webhook with the reason `revoked_key`, so you can find any deployment still using it. ### Rotating a key - Create the new key in the same environment. - Deploy it everywhere the old one is used. - Watch the old key's usage fall to zero in **SDK keys**. - Revoke the old key. ## Regions The region in a key, such as `us`, decides which regional API accepts it. A key is refused by any other region's API. See [regions and data residency](https://docs.fingerly.io/docs/regions). --- # Regions and data residency > Choose where your visitors' data lives. Each organization belongs to one region, and identification data never leaves it. Fingerly runs a separate data plane in each region. Your organization belongs to one region, chosen once, and the data about your visitors is collected, processed and stored there. ## Available regions | Region | API | Key region | Status | | --- | --- | --- | --- | | United States | `https://us.api.fingerly.io` | `us` | Available | | European Union | `https://eu.api.fingerly.io` | `eu` | Coming soon | ## Choosing a region The owner chooses the region when setting up a new organization. The choice is permanent: an organization cannot move between regions. To use two regions, create an organization in each. ## What stays in the region | In your region | In the United States | | --- | --- | | Identification events and archived submissions | Accounts and sign-in | | Visitor identifiers | Organizations, members and invitations | | SDK keys and proxy keys | Billing and payments | | Risk weights and thresholds | | | Webhook endpoints, secrets and deliveries | | | Analytics, queues and backups | | When you view visitor data in the dashboard, your browser reads it directly from your region's API. It does not pass through the United States. ## Routing - Every key carries its region, and the SDKs send requests to that region's API automatically. - A key presented to another region's API is refused. - A [proxy](https://docs.fingerly.io/docs/proxy-integrations) forwards only to the region in its proxy key. > **Note:** Where a CDN or your own proxy processes requests before they reach Fingerly is governed by that provider's settings, not by Fingerly. --- # Billing and usage > Prepaid, pay-as-you-go pricing: what an identification costs, what is free, how credit and auto top-up work, and what happens when the balance runs out. Fingerly is prepaid. You add credit, and each production identification is paid from it. There are no plans, minimums, monthly fees or seat charges. ## Prices | Request | Price | | --- | --- | | Production identification | $0.003 per request | | Production request that could not be identified (`identifiable: false`) | $0.0005 per request | Prices are in US dollars. ## What is free - Every request with a development or staging key. - Retries answered as duplicates of the same `Idempotency-Key`. - Requests Fingerly refuses: `401`, `402` and `429`. - Requests whose network lookup could not run (`state: unavailable`). - Deferred reports and attestation challenges. - Reading events with a secret key, and webhooks. ## Credit | | | | --- | --- | | Signup credit | $3, enough for 1,000 identifications. Once per email address, no card needed. | | Top-ups | From $5 to $5,000, by card. | | Expiry | Purchased and signup credit never expire. Promotional credit has an expiry date and is spent first. | Add funds in **Settings > Billing**. Credit is added as soon as the payment is confirmed. ## Auto top-up Auto top-up adds a fixed amount by card when your balance falls below a threshold, so traffic never stops for lack of credit. | Setting | Range | Default | | --- | --- | --- | | Amount | $5 to $5,000 | | | Threshold | Below the amount | | | Top-ups per day | 1 to 20 | 3 | | Time between top-ups | At least 1 minute | 5 minutes | Auto top-up needs a card on file. After three declined payments in a row it pauses, and you can resume it from **Settings > Billing** once the card is fixed. ## When the balance runs out Production identify requests are refused with `402 no_credit` until you add funds, and each refusal sends an [`identification.refused`](https://docs.fingerly.io/reference/webhooks/identification-refused) webhook. Development and staging keys keep working. A burst of traffic at the moment the balance reaches zero can take it slightly below zero. > **Warning:** Watch for `identification.refused` with the reason `no_credit`, or turn on auto top-up, so that identification does not stop unnoticed. ## Reading your balance - **Settings > Billing** shows the balance, your cards and auto top-up. - Identify responses carry `Fingerly-Balance-Micros`, the balance in millionths of a dollar. - The [`usage.daily_settled`](https://docs.fingerly.io/reference/webhooks/usage-daily-settled) webhook reports each day's checks and cost. ## Usage **Settings > Usage** shows metered checks per day and per key, billable and test traffic, and refused requests. Usage is settled once a day, shortly after midnight UTC. Owners, admins and members with the billing role can manage billing. --- # Rate limits > How identification is rate limited per organization, what a 429 looks like, and how the SDKs and your code should respond. Identify requests are rate limited per organization, across all of its keys and environments. Other endpoints are not rate limited. ## Limits | | Default | | --- | --- | | Sustained rate | 1,000 identify requests per second | | Burst | Up to 2,000 requests at once, refilling at the sustained rate | Development and staging traffic counts towards the same limit. If you need a higher limit, email [support@fingerly.io](mailto:support@fingerly.io). ## Headers | Header | Meaning | | --- | --- | | `RateLimit-Limit` | Your organization's limit, per second. | | `RateLimit-Remaining` | What remained when the request was admitted. | | `Retry-After` | On `429`, seconds to wait. | ## When you exceed the limit ```json 429 Too Many Requests { "error": { "code": "rate_limited", "message": "this organisation is sending faster than its rate limit allows", "status": 429 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` - A refused request is not charged, and its `Idempotency-Key` is released so the retry is processed normally. - Each refusal sends an [`identification.refused`](https://docs.fingerly.io/reference/webhooks/identification-refused) webhook with the reason `rate_limited`. - The client SDKs retry `429` with jittered backoff. > **Tip:** Identify at meaningful moments rather than on every page view, and use the framework SDKs, which share one identification per page. --- # Team and roles > Invite your team to an organization and give each person a role: owner, admin, developer or billing. An organization has one owner and any number of members. Each member has one role. ## Roles | Capability | Owner | Admin | Developer | Billing | | --- | --- | --- | --- | --- | | View dashboards, events, visitors and analytics | Yes | Yes | Yes | Yes | | View risk weights | Yes | Yes | Yes | Yes | | Create and revoke SDK keys | Yes | Yes | Yes | No | | Manage webhooks | Yes | Yes | Yes | No | | Change risk weights and the threshold | Yes | Yes | No | No | | View, issue and revoke proxy keys | Yes | Yes | No | No | | Issue and revoke management keys | Yes | Yes | No | No | | Invite, change and remove members | Yes | Yes | No | No | | Rename the organization | Yes | Yes | No | No | | Manage billing and view usage | Yes | Yes | No | Yes | | Choose the region and close the organization | Yes | No | No | No | ## Invite a member In **Settings > Team & roles**, invite a person by email with the role they should have: admin, developer or billing. Share the invitation link with them. Invitations expire after seven days, and resending one keeps the same link and deadline. ## Change or remove members - Owners and admins can change a member's role, suspend a member, or remove one. - A suspended member keeps their role but can do nothing until they are reinstated. - Nobody can change or remove the owner, or their own membership. ## Signing in Members sign in with email and password, an email link, Google, Microsoft or GitHub. Each person manages their sign-in methods in **Account settings**. > **Note:** One person can belong to several organizations, with a different role in each. --- # Using the dashboard > A tour of the Fingerly dashboard: identification events and visitors, signals and the suspect score, insights, integration settings and billing. The dashboard is where you manage keys, webhooks, weights, your team and billing, and where you explore what Fingerly has identified. ## Dashboard The home page shows whether your organization is accepting traffic, your balance and the period's cost, key metrics against the previous period, identification volume, and breakdowns you choose. ## Identification | Page | What it shows | | --- | --- | | Events | Every identification with its visitor, score, level and signals. Filter the table and open an event for its full detail. | | Visitors | Unique visitors by kind (human, AI bot, automation, search crawler), by browser or app, and by country, with the share of returning visitors. | ## Smart Signals | Page | What it shows | | --- | --- | | Signal overview | Which signals fire, how often, and how that changed from the previous period, per platform. | | Suspect Score | Your organization's weights, weighting modes and threshold. Edit them here. | | Signal reference | What every individual signal means. | ## Insights | Page | What it shows | | --- | --- | | Traffic | Request volume, time of day, SDK versions and environments. | | Suspect score trends | How scores are distributed over time against your threshold, and which signals contribute most. | | Networks & geography | Countries, networks and anonymity networks behind your traffic. | ## Integration | Page | What it does | | --- | --- | | SDK keys | Create and revoke keys, edit allowed origins, and see each key's usage over 30 days. | | Proxy keys | Issue and revoke the keys your first-party proxies use. | | Management keys | Issue and revoke keys for the management API. | | Webhooks | Add, edit and pause endpoints, and read the delivery log. | ## Settings | Page | What it does | | --- | --- | | Organisation | Name, owner, whether the organization is accepting traffic, and closing it. | | Account settings | Your profile, sign-in methods and memberships. | | Team & roles | Members, invitations and roles. | | Billing | Balance, cards, top-ups and auto top-up. | | Usage | Metered checks per day and per key. | > **Note:** What you can see and change depends on your [role](https://docs.fingerly.io/docs/team-and-roles). - [API keys and environments](https://docs.fingerly.io/docs/api-keys): Create your first keys. - [Webhooks](https://docs.fingerly.io/docs/webhooks): Register an endpoint. --- # Privacy and consent > What Fingerly collects and what it never does, your role and Fingerly's under data protection law, and how to gate the SDK behind consent where you need it. Fingerly identifies devices to prevent fraud. This page describes what that involves, so you can describe it accurately to your visitors and choose the right legal basis. ## Roles You decide when and why visitors are identified, so you are the **controller** of that data. Fingerly processes it on your behalf as your **processor**. ## What is collected - Characteristics of the device, operating system and browser or app, read within a strict time budget. - On iOS and Android, device identifiers the operating system makes available to apps. Never the advertising identifier. See [App Store and Google Play](https://docs.fingerly.io/docs/app-store-privacy). - The visitor's IP address, which Fingerly uses to determine the country, the network, and whether the connection comes through Tor, a VPN, a proxy or a hosting provider. - The tag you send, and the time of the request. From these, Fingerly derives the visitor ID, the suspect score, and the signals and weights behind it. ## What is never collected - Page content, form fields or anything a visitor types. - Mouse movements, keystrokes, clipboard contents or session recordings. - Contacts, photos, precise location or anything behind a permission prompt. No SDK ever shows one. ## What Fingerly does not do - Set cookies, or store anything in the visitor's browser. - Link visitors across organizations. The same device has a different visitor ID for every customer. - Sell or share visitor data, or use it for advertising. In apps, the iOS and Android SDKs keep one random installation identifier in your app's own storage, to recognise the installation over time. It is never shared with other apps. ## Consent Every SDK takes a `consent` option: `granted`, `pending` or `denied`. It defaults to `granted`, which collects as soon as you call the SDK. Where your legal basis for fraud prevention requires consent, load the SDK with `pending` and pass on your consent tool's answer with `setConsent()`. - Until consent is `granted`, `identify()` and `collect()` read nothing from the device, send nothing, and fail with a consent error whose code is `consent_required`. - Withdrawing consent while an identification is running stops it before anything more is sent. - `pending` and `denied` behave the same. Use whichever describes your state. - Loading the SDK never collects or sends anything, whatever the consent state. ```ts JavaScript import { load, ConsentError } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…', consent: 'pending' }) // From your consent tool (OneTrust, Cookiebot, your own banner) consentManager.onChange((consent) => { fingerly.setConsent(consent.fraudPrevention ? 'granted' : 'denied') }) try { await fingerly.identify({ tag: 'signup' }) } catch (error) { if (error instanceof ConsentError) { // No consent: decide on your server without a request ID. } } ``` ```tsx React export function Providers({ children }: { children: React.ReactNode }) { const { fraudPrevention } = useConsent() return ( {children} ) } ``` ```swift Swift let fingerly = try await Fingerly.load(apiKey: "fly_pk_us_production_…", consent: .pending) // From your consent tool's callback fingerly.setConsent(accepted ? .granted : .denied) ``` ```kotlin Kotlin val fingerly = Fingerly.load(context, apiKey = "fly_pk_us_production_…", consent = ConsentState.PENDING) // From your consent tool's callback, on any thread fingerly.setConsent(if (accepted) ConsentState.GRANTED else ConsentState.DENIED) ``` Framework bindings wait for consent: identification set to run on mount starts once consent becomes `granted`, and not before. To connect OneTrust, Cookiebot, Google Consent Mode or your own banner, see [consent tools](https://docs.fingerly.io/docs/consent-tools). > **Warning:** Under the ePrivacy Directive, reading information from a visitor's device generally needs consent unless it is strictly necessary for a service the visitor requested. Whether fraud prevention qualifies depends on your circumstances. Take legal advice for your use case. ## Telling your visitors Your privacy notice should say that you use device identification for fraud prevention and security, that Fingerly processes that data on your behalf, and how long it is kept. See [data retention](https://docs.fingerly.io/docs/data-retention). ## Requests from visitors To have data about a visitor accessed or deleted, email [support@fingerly.io](mailto:support@fingerly.io) with the visitor ID and the organization it belongs to. --- # Consent tools > Gate identification behind your consent tool: load the SDK with consent pending, then pass on the answer from OneTrust, Cookiebot, Google Consent Mode or your own banner. Every Fingerly SDK takes a `consent` state. Until it is `granted`, the SDK reads nothing from the device, sends nothing, and `identify()` rejects with a `ConsentError`. The SDKs do not talk to consent tools themselves: your code passes the tool's answer on with `setConsent()`. This page shows that code for common tools. > **Warning:** Whether identification for fraud prevention needs consent, and which category it belongs in, is a legal decision for you. If your assessment is that it does not need consent, leave `consent` at its default, `granted`, and skip this page. See [privacy and consent](https://docs.fingerly.io/docs/privacy-and-consent). ## The pattern ```ts fingerly.ts import { load } from '@fingerly/web-js' // Nothing is read or sent until setConsent('granted'). export const fingerly = await load({ apiKey: 'fly_pk_us_production_…', consent: 'pending' }) ``` - Load with `pending` before your consent tool has answered. - Call `setConsent('granted')` when the visitor accepts the category you put Fingerly in, and `setConsent('denied')` when they refuse or withdraw it. `pending` and `denied` behave the same. - Apply the tool's answer on page load too: a returning visitor's choice is already stored. - Withdrawing consent stops an identification that is already running before anything more is sent. ## OneTrust OneTrust lists the categories a visitor has accepted in `OnetrustActiveGroups`, and calls `OptanonWrapper()` when its banner loads and every time consent changes. Use the ID of the category you assigned Fingerly to, such as `C0003` for Functional cookies in OneTrust's default categories. ```ts onetrust.ts import { fingerly } from './fingerly' // What OneTrust puts on the page, for TypeScript. declare global { interface Window { OnetrustActiveGroups?: string OptanonWrapper?: () => void OneTrust?: unknown } } const CATEGORY = 'C0003' // the OneTrust category you assigned Fingerly to function applyOneTrust() { const accepted = (window.OnetrustActiveGroups ?? '').split(',').includes(CATEGORY) fingerly.setConsent(accepted ? 'granted' : 'denied') } // Keep any OptanonWrapper already on the page. const previous = window.OptanonWrapper window.OptanonWrapper = () => { previous?.() applyOneTrust() } if (window.OneTrust) applyOneTrust() // the banner loaded before this code ``` ## Cookiebot Cookiebot exposes the visitor's answer per category on `Cookiebot.consent`, and fires `CookiebotOnConsentReady` on the window once consent is known, including after every change. ```ts cookiebot.ts import { fingerly } from './fingerly' // What Cookiebot puts on the page, for TypeScript. declare global { interface Window { Cookiebot?: { hasResponse?: boolean; consent?: Record } } } const CATEGORY = 'preferences' // necessary, preferences, statistics or marketing function applyCookiebot() { const accepted = Boolean(window.Cookiebot?.consent?.[CATEGORY]) fingerly.setConsent(accepted ? 'granted' : 'denied') } window.addEventListener('CookiebotOnConsentReady', applyCookiebot) if (window.Cookiebot?.hasResponse) applyCookiebot() // answered before this code ran ``` ## Google Consent Mode Consent Mode passes consent to Google's tags; it gives other scripts no supported way to read it. Set Fingerly's state in the same place you call `gtag('consent', 'update')`. The closest Consent Mode type is `security_storage`, which Google describes as covering fraud prevention. ```ts consent.ts import { fingerly } from './fingerly' export function saveConsent(choices: { security: boolean; analytics: boolean; ads: boolean }) { const state = (allowed: boolean) => (allowed ? 'granted' : 'denied') gtag('consent', 'update', { security_storage: state(choices.security), analytics_storage: state(choices.analytics), ad_storage: state(choices.ads), ad_user_data: state(choices.ads), ad_personalization: state(choices.ads), }) fingerly.setConsent(state(choices.security)) } ``` > **Tip:** If a consent platform such as OneTrust or Cookiebot sets Consent Mode for you, read the answer from that platform instead, as in the sections above. ## Your own banner ```ts banner.ts import { fingerly } from './fingerly' const saved = localStorage.getItem('consent.fraud-prevention') if (saved) fingerly.setConsent(saved === 'yes' ? 'granted' : 'denied') acceptButton.addEventListener('click', () => { localStorage.setItem('consent.fraud-prevention', 'yes') fingerly.setConsent('granted') }) ``` ## Frameworks The framework SDKs take the same `consent` option, and wait for it: identification set to run on mount starts once consent becomes `granted`. | SDK | Starting state | Change it with | | --- | --- | --- | | [React](https://docs.fingerly.io/docs/sdks/react), [Next.js](https://docs.fingerly.io/docs/sdks/nextjs) | `` | The `consent` prop, or `useFingerly().setConsent()` | | [Vue](https://docs.fingerly.io/docs/sdks/vue) | `createFingerly({ apiKey, consent: 'pending' })` | `useFingerly().setConsent()` | | [Nuxt](https://docs.fingerly.io/docs/sdks/nuxt) | `fingerly: { consent: 'pending' }` in `nuxt.config` | `useFingerly().setConsent()` | | [Svelte](https://docs.fingerly.io/docs/sdks/svelte) | `setupFingerly({ apiKey, consent: 'pending' })` | `useFingerly().setConsent()` | | [Angular](https://docs.fingerly.io/docs/sdks/angular) | `provideFingerly({ apiKey, consent: 'pending' })` | `injectFingerly().setConsent()` | ```tsx React import { useEffect } from 'react' import { useFingerly } from '@fingerly/react' // Window.OnetrustActiveGroups, OptanonWrapper and OneTrust are declared // as in onetrust.ts. export function OneTrustBridge() { const fingerly = useFingerly() useEffect(() => { const apply = () => { const accepted = (window.OnetrustActiveGroups ?? '').split(',').includes('C0003') fingerly.setConsent(accepted ? 'granted' : 'denied') } const previous = window.OptanonWrapper window.OptanonWrapper = () => { previous?.() apply() } if (window.OneTrust) apply() }, [fingerly]) return null } ``` ## Mobile apps The iOS, Android, React Native, Flutter and Lynx SDKs take the same states. Load with `pending`, and call `setConsent` from your consent SDK's callback. In React Native, Flutter and Lynx the state is shared by the whole app, so pass it on every `load`. See the [samples on privacy and consent](https://docs.fingerly.io/docs/privacy-and-consent#consent). ## Check that it works - Before consent, the browser's Network panel shows no request to `/api/v1/identify`, and `identify()` rejects with a `ConsentError` whose `code` is `consent_required`. - After accepting, the next identification succeeds without reloading the page. - After withdrawing, identification stops again. > **Note:** Without consent there is no request ID. Your server should decide the action with less evidence, the same way it does when identification fails. See [server-side verification](https://docs.fingerly.io/docs/server-side-verification#without-a-request-id). --- # App Store and Google Play privacy > How to answer Apple's App Privacy questions and Google Play's Data safety form for the data the Fingerly iOS, Android and cross-platform SDKs collect. Both stores ask what data your app collects, including data collected by the SDKs inside it. This page gives the answers for the Fingerly mobile SDKs. React Native, Flutter and Lynx apps use the same native SDKs and collect nothing more, so the same answers apply. > **Note:** Your app's declarations are yours to make, and they cover everything your app collects, not only Fingerly. These answers describe the SDKs at version 0.1. Check them against how you use Fingerly, and take advice where you are unsure. ## What the mobile SDKs collect - **Device identifiers.** Identifiers the operating system makes available to apps, and a random installation identifier the SDK keeps in your app's own storage: the Keychain on iOS, and private app preferences on Android. Never the advertising identifier. - **Device and app characteristics.** Hardware, operating system, display, language and time zone settings, battery state, network type, and the integrity of the device and of your app. On Android, also the mobile network operator's name, the store that installed your app, and whether specific apps from a fixed list declared in the SDK's manifest are installed. - **The IP address** each request comes from. Fingerly determines the country and the network from it. - **Your tag**, the string you pass to `identify`. The SDKs never collect precise location, contacts, photos, messages, anything your users type, browsing history or advertising data, and never show a permission prompt. They make no App Tracking Transparency request because they do not track. ## Apple: App Privacy details In App Store Connect, under **App Privacy**, declare these data types for Fingerly: | Data type | Collected | Linked to the user | Used for tracking | Purpose | | --- | --- | --- | --- | --- | | Identifiers: **Device ID** | Yes | See below | No | App Functionality | | Location: **Coarse Location** | Yes, derived from the IP address | See below | No | App Functionality | | Other Data: **Other Data Types** | Yes, device and app characteristics | See below | No | App Functionality | | Identifiers: **User ID** | Only if your tags contain a user or account ID | Yes | No | App Functionality | Apple's **App Functionality** purpose includes preventing fraud and implementing security measures, which is what Fingerly is for. ### Linked to the user Fingerly does not know who your users are. The data becomes linked to a user's identity when your app or your server stores the request ID or the visitor ID with their account, as the [use-case recipes](https://docs.fingerly.io/docs/use-cases) do. If you do that, answer **Yes**. ### Tracking Answer **No**. Fingerly processes the data on your behalf, only to prevent fraud and keep your app secure. It does not combine it with other companies' data, the same device has a different visitor ID for every Fingerly customer, and nothing is used for advertising. Apple's definition of tracking does not include data used solely for fraud prevention or security on your behalf. This stays true only if your own use of the data stays within those purposes. ### Privacy manifest > **Warning:** The iOS SDK does not include a privacy manifest (`PrivacyInfo.xcprivacy`) yet. Xcode's privacy report will not list Fingerly, and App Store Connect may report required reason APIs used by the SDK when you upload a build. Email [support@fingerly.io](mailto:support@fingerly.io) before you submit an app that includes the iOS SDK. ## Google Play: Data safety In Play Console, under **App content > Data safety**, declare these data types for Fingerly: | Data type | Collected | Shared | Purpose | | --- | --- | --- | --- | | Device or other IDs | Yes | No | Fraud prevention, security, and compliance | | Location: **Approximate location** | Yes, derived from the IP address | No | Fraud prevention, security, and compliance | | App activity: **Installed apps** | Yes, a fixed list of specific apps | No | Fraud prevention, security, and compliance | | App info and performance: **Diagnostics** | Yes, device characteristics such as battery state | No | Fraud prevention, security, and compliance | ### The other questions | Question | Answer for Fingerly | | --- | --- | | Is the data shared? | **No.** Fingerly is a service provider processing the data on your behalf, which Google does not count as sharing. | | Is it processed ephemerally? | **No.** Identification events are kept for 30 days. See [data retention](https://docs.fingerly.io/docs/data-retention). | | Is collection required or optional? | **Required**, unless you load the SDK with a `consent` state other than `granted` and let users decline. Then **optional**. See [consent tools](https://docs.fingerly.io/docs/consent-tools). | | Is all data encrypted in transit? | **Yes.** The SDK sends everything to Fingerly over HTTPS. If you set a custom `endpoint`, it must be an `https://` URL for this to stay true. | | Can users request deletion? | **Yes**, through your own process. Fingerly deletes a visitor's data when you ask at [support@fingerly.io](mailto:support@fingerly.io). | ### Permissions and package visibility - The SDK's manifest declares only `INTERNET` and `ACCESS_NETWORK_STATE`. Both are granted at install time, so there is no prompt and no Permissions Declaration Form. - It checks for specific apps through a `` list of package names, and never requests `QUERY_ALL_PACKAGES`. - It uses no Google Play services and no advertising ID, so you do not need to declare the `AD_ID` permission for Fingerly. ### Backups The SDK does not exclude its installation identifier from Android Auto Backup, so your app's backup rules decide whether it is backed up. To keep it on one device, exclude the `io.fingerly.sdk.install` shared preferences file in your backup rules. ## Your privacy policy Both stores link to your privacy policy. Say that your app uses device identification from Fingerly to prevent fraud and keep accounts secure, what that involves, and how long the data is kept. See [privacy and consent](https://docs.fingerly.io/docs/privacy-and-consent). --- # Data retention > How long Fingerly keeps identification events, aggregated analytics, usage records and webhook deliveries, and how to keep your own copy for longer. Fingerly keeps each kind of data for as long as it is needed for its purpose. | Data | Kept for | Readable through | | --- | --- | --- | | Identification events, including IP addresses | 30 days | The dashboard, the server API | | Deferred reports | 30 days | The server API, with their event | | Webhook delivery history | 30 days | The dashboard | | Per-address daily summaries | 90 days | Used for detection only | | Velocity counters | 8 days | Used for detection only | | Visitor identities, which recognise returning devices | 180 days after the device was last seen | Used for identification only | | Aggregated analytics | Up to 800 days, in aggregate form | Dashboard insights | | Usage and billing records | About 3 years | Settings > Usage | ## Visitor identities A visitor identity is deleted 180 days after its device was last seen. A device that returns after that is identified as a new visitor, with a new `visitor_id` and `visitor_is_new` set to `true`. ## Archived submissions The archived copy of each submission does not yet expire automatically. A retention period for it is being introduced. Until then, contact support to have archived submissions deleted. ## Keeping your own copy If you need events for longer than 30 days, for example for chargeback evidence, keep your own copy: subscribe to [`identification.completed`](https://docs.fingerly.io/reference/webhooks/identification-completed), or page through [List events](https://docs.fingerly.io/reference/list-events) on a schedule. ```ts Nightly export const to = startOfToday() const from = new Date(to.getTime() - 24 * 60 * 60 * 1000) for (let page = 1; ; page++) { const { rows, page_size } = await fingerly.events.list({ from, to, page, limit: 200 }) await warehouse.insert(rows) if (rows.length < page_size) break } ``` ## Closing an organization The owner can close an organization in **Settings > Organisation**. To have all of its data deleted, contact support. > **Note:** Questions about retention: [support@fingerly.io](mailto:support@fingerly.io). --- # Security > How Fingerly protects your keys, webhook secrets and visitor data, what stays in your region, what you are responsible for, and how to report a vulnerability. This page describes how Fingerly is built to protect your integration and your visitors' data. It describes only what Fingerly does today. ## Keys - **Stored as keyed hashes.** The secret part of every SDK key, proxy key and management key is stored only as an HMAC-SHA256 hash, under a hashing key that is itself protected by a cloud key management service in the key's region. Fingerly cannot show a key again, so each is shown once, when it is created. Afterwards the dashboard shows only its prefix and last four characters. - **Separated by power.** Public keys can only submit identifications. Secret keys read events and are refused when a request carries a browser `Origin` header; the only exception is a development secret key used from the Try it panel on these docs, which reads development events. Proxy keys only forward identify requests and can never read events. Management keys manage keys, webhook endpoints and risk weights with the permissions of their role, are always refused from browsers, and cannot issue other management keys. - **Bound to origins.** In browsers, a public key is accepted only from origins that exactly match its allowed list, and a key with no allowed origins refuses every browser request. - **Uninformative when refused.** An unknown, revoked, expired or disallowed key receives the same `401`, so a refusal reveals nothing about which keys exist. - **Scoped.** Every key belongs to one organization and one region, and every SDK key and proxy key to one environment. A key sent to another region's API is refused. ## Webhooks - **Secrets encrypted at rest.** Each endpoint's signing secret is encrypted with AES-256-GCM and bound to its endpoint, under an encryption key protected by a key management service in your region. The secret is shown once. - **Signed deliveries.** Every delivery carries an HMAC-SHA256 signature over a timestamp and the body, so you can reject forged and replayed requests. Secrets can be rotated with an overlap window. See [webhooks](https://docs.fingerly.io/docs/webhooks#verify-the-signature). - **HTTPS to public addresses only.** Endpoint URLs must use `https://`, without credentials in the URL, and every address they resolve to must be public: private, loopback, link-local and reserved ranges are refused. The check runs again at each delivery, which connects only to the address it checked, through no proxy. - **No redirects.** Deliveries do not follow redirects, and time out after 10 seconds. ## Regions Each region is a separate deployment and security boundary. The data about your visitors, and what protects it, stays in your organization's region. | In your region | In the United States control plane | | --- | --- | | Identification events, archived submissions and visitor identities | Accounts and sign-in | | SDK keys, proxy keys and their hashing key | Organizations, members and invitations | | Webhook endpoints, encrypted secrets and deliveries | Billing | | Risk weights, analytics and backups | | - When you view visitor data in the dashboard, your browser reads it directly from your region's API with a short-lived grant. It does not pass through the control plane. - Control-plane data and regional data are kept in separate databases with separate database roles and credentials. See [regions and data residency](https://docs.fingerly.io/docs/regions). ## Infrastructure - **No exposed services.** The API is reached only through Cloudflare. Databases, caches and internal services accept no connections from the internet. - **Encrypted backups.** Backups are encrypted before they leave the server and are deleted after 35 days. - **Rate limits.** Identification is rate limited per organization. See [rate limits](https://docs.fingerly.io/docs/rate-limits). ## Payments Card payments are taken by Stripe on its hosted checkout. Card numbers never reach Fingerly: for each card on file, Fingerly keeps only the brand, the last four digits and the expiry date. ## Visitor data - The browser SDK sets no cookies and stores nothing on the visitor's device. The mobile SDKs keep one random installation identifier in your app's own storage. - Visitor IDs are scoped to your organization. The same device has a different visitor ID for every Fingerly customer, so visitor IDs cannot link people across companies. - Forwarded visitor details, such as a proxy's client address, are trusted only on requests authenticated with a proxy key or a secret key. - Identification events are readable for 30 days. See [data retention](https://docs.fingerly.io/docs/data-retention). ## Dashboard access - Members sign in with email and password, an email link, Google, Microsoft or GitHub. - Every member has one role, and the API enforces what each role may do. See [team and roles](https://docs.fingerly.io/docs/team-and-roles). - Owners and admins can suspend or remove a member. ## Certifications Fingerly does not hold SOC 2 or ISO 27001 certification, and claims no other security or privacy certification. ## Your part | Practice | Why | | --- | --- | | Keep secret keys, proxy keys and management keys on your servers, in a secret manager. | Anyone holding a secret key can read your events for its environment, and a management key can issue new secret keys. | | List only your own origins on public keys. | Allowed origins are what stop other sites spending your balance in browsers. | | Decide on your server, from the event you read with a secret key. | Anything the browser or app reports can be edited. See [server-side verification](https://docs.fingerly.io/docs/server-side-verification). | | Verify every webhook signature over the raw body. | Your endpoint is public. | | Resolve the visitor's address in your proxy from infrastructure you control. | Visitors can write `X-Forwarded-For`. | | Revoke a key the moment it leaks, and rotate keys when people leave. | Revocation applies immediately. | | Give members the least powerful role that works. | Developers and billing members cannot change weights, proxy keys or the team. | ## Report a vulnerability If you believe you have found a security vulnerability in Fingerly, email [support@fingerly.io](mailto:support@fingerly.io) with **Security** in the subject. Include what you found, where, and the steps to reproduce it. - Test only against an organization and keys of your own. - Do not access, change or delete other customers' data, and stop as soon as you reach data that is not yours. - Do not degrade the service, for example with load or denial-of-service testing. - Give us a reasonable time to fix the issue before you disclose it. > **Note:** Fingerly does not run a bug bounty programme. - [Privacy and consent](https://docs.fingerly.io/docs/privacy-and-consent): What is collected, and your role under data protection law. - [API keys and environments](https://docs.fingerly.io/docs/api-keys): Create, restrict and revoke keys. --- # Browser and device support > The browsers, operating systems, frameworks and server runtimes Fingerly SDKs support, and how privacy-focused browsers are handled. ## Browsers The browser SDK supports every browser that runs ES2020 with `fetch` and `AbortController`. | Browser | Supported | Tested on | | --- | --- | --- | | Chrome and Chromium browsers | Current versions | Chrome 151 | | Edge | Current versions | | | Firefox | Current versions, including strict tracking protection | Firefox 154 | | Safari on macOS | Current versions | Safari 26.5 | | Safari on iOS and iPadOS | Current versions | iOS 26 | | Brave | Current versions | Brave with Shields | | Tor Browser | Current versions, all security levels | Standard and Safest | Serve pages over HTTPS. The SDK runs on insecure pages, but several signals are only available in a secure context. `localhost` counts as secure during development. ## Privacy-focused browsers Browsers that resist fingerprinting are still identified where they allow it, and scored either way. When a browser gives too little to identify anyone, the response has `identifiable: false`, the `fingerprint_suppressed` signal fires, and the request costs less. The SDK never triggers a permission prompt in these browsers either. ## Mobile | SDK | Minimum | | --- | --- | | [iOS](https://docs.fingerly.io/docs/sdks/ios) | iOS 13, tvOS 13, Mac Catalyst 13 | | [Android](https://docs.fingerly.io/docs/sdks/android) | Android 5.0 (API 21) | | [React Native](https://docs.fingerly.io/docs/sdks/react-native) | React Native 0.74, iOS 13, Android 5.0 | | [Flutter](https://docs.fingerly.io/docs/sdks/flutter) | Flutter 3.10, iOS 13, Android 5.0 | | [Lynx](https://docs.fingerly.io/docs/sdks/lynx) | Lynx 4.1, iOS 13, Android 6.0 | ## Web frameworks | SDK | Minimum | | --- | --- | | [React](https://docs.fingerly.io/docs/sdks/react) | React 19 | | [Next.js](https://docs.fingerly.io/docs/sdks/nextjs) | Next.js 15, React 19 | | [Vue](https://docs.fingerly.io/docs/sdks/vue) | Vue 3.3 | | [Nuxt](https://docs.fingerly.io/docs/sdks/nuxt) | Nuxt 3 | | [Svelte](https://docs.fingerly.io/docs/sdks/svelte) | Svelte 5 | | [Angular](https://docs.fingerly.io/docs/sdks/angular) | Angular 19 | ## Server | SDK | Minimum | | --- | --- | | [Node.js](https://docs.fingerly.io/docs/sdks/node) | Node.js 18.16, or any runtime with web `fetch` | | [Python](https://docs.fingerly.io/docs/sdks/python) | Python 3.9 | | [Go](https://docs.fingerly.io/docs/sdks/go) | Go 1.21 | | [Java](https://docs.fingerly.io/docs/sdks/java) | Java 11 | | [.NET](https://docs.fingerly.io/docs/sdks/dotnet) | .NET 6 | | [PHP](https://docs.fingerly.io/docs/sdks/php) | PHP 8.1 | | [Ruby](https://docs.fingerly.io/docs/sdks/ruby) | Ruby 3.1 | | [Rust](https://docs.fingerly.io/docs/sdks/rust) | Rust 1.75, Tokio | Any other language can use the [HTTP API](https://docs.fingerly.io/reference/overview) directly. --- # Glossary > Definitions of the terms used across Fingerly: visitor, identification, event, request ID, signal, group, trigger, weight, score, level and more. The terms these docs, the API and the dashboard use, and what each means precisely. ## Identity ### Visitor A device as Fingerly recognises it: a browser, or an app on a phone. A visitor is not a person. Several people can share one device, and one person can use several. ### Visitor ID `visitor_id`. The stable identifier Fingerly issues for a visitor: twenty letters and digits, scoped to your organization. The same device on another customer's site has a different visitor ID. See [visitor identification](https://docs.fingerly.io/docs/visitor-identification). ### Visitor confidence `visitor_confidence`, from 0 to 100. `100` means this exact device was seen before, `85` to `99` that it was recognised after it changed, and `0` that a new visitor ID was issued. ### New visitor `visitor_is_new`. `true` the first time your organization sees a visitor. ### Identifiable `identifiable`. `false` when a device gave too little to identify anyone. Such a device gets a new visitor ID each time, is still scored, raises `fingerprint_suppressed`, and costs less. ## Requests and events ### Identification One call to an SDK's `identify()`: the SDK collects signals, the server identifies the visitor, scores the session and stores the result as an event. Production identifications are billed. ### Request ID `request_id`. The ID of one identification, a UUIDv7. The client sends it to your server, which reads the event with it. ### Event The stored record of an identification: the visitor, the network, the score and the signal groups that fired. Readable with a secret key for 30 days. See [reading events](https://docs.fingerly.io/docs/reading-events). ### Tag `tag`. Your own string for an identification, such as `login` or `checkout:8412`, echoed on the event and in webhooks. Your server compares it to bind an identification to the action it was made for. ### State `state`. `enriched` when the identification was fully processed, or `unavailable` when the network lookup could not run, in which case nothing is scored and nothing is billed. ### Deferred report Signals a browser collects after the initial answer, attached to the same request. Archived with the event; it never changes the visitor, the score or billing. ### Verdict A local, advisory result an SDK computes on the device, such as `automation` or `jailbreak`. Useful for the interface, never for decisions. See [client-side verdicts](https://docs.fingerly.io/docs/client-verdicts). ## Risk ### Signal One thing Fingerly detects about a session, such as `tor` or `automation`. A signal's name never changes meaning. ### Signal group Signals grouped by what they tell you, such as `automation` in the `bot` group. Stored events list their triggers by group, and the dashboard reports by group. See [signals](https://docs.fingerly.io/docs/signals). ### Trigger A signal that fired on an identification, listed in `triggers` with its weight and confidence. In the identify response and `visitor.suspect`, `signal` is the individual signal and `group` its group; on a stored event, `signal` is the group. ### Weight The points a signal adds to the suspect score when it fires. A whole number you can change, per platform. See [risk weights](https://docs.fingerly.io/docs/risk-weights). ### Confidence On a trigger, how strong the evidence for that signal was: `low`, `medium` or `high`. Reported alongside the weight, never multiplied into it. ### Suspect score `suspect_score`. The sum of the weights of every signal that fired. Not a percentage and not capped. Absent when a request was not scored, which is different from `0`. See [suspect score](https://docs.fingerly.io/docs/suspect-score). ### Threshold The score at which the level becomes `high`. `30` by default. ### Level `suspect_level`. The score compared with the threshold: `low` below half of it, `medium` from half, `high` from the threshold. ### Profile A set of weights, a threshold and weighting modes for one platform. A key's own profile is used if it has one, otherwise your organization's, otherwise the defaults. ## Keys and account ### Organization Your account on Fingerly: its members, keys, data, balance and region. ### Environment `development`, `staging` or `production`. Every key belongs to one. Only production is billed. Each environment's events, dashboards and webhooks are kept apart, while visitor IDs are shared across them. Webhooks call production `live` and the other two `test`. ### Region Where an organization's visitor data is processed and stored, such as `us`. Chosen once and written into every key. See [regions](https://docs.fingerly.io/docs/regions). ### Public key `fly_pk_…`. Used by client SDKs to identify visitors. Accepted from browsers only on its allowed origins. ### Secret key `fly_sk_…`. Used by your backend to read events. Refused from browsers. ### Management key `fly_mk_…`. Used by your automation to manage keys, webhook endpoints and risk weights through the [management API](https://docs.fingerly.io/reference/management/overview), with the permissions of the `admin` or `developer` role. ### Proxy key `fly_px_…`. Lets a first-party proxy forward identify requests with the visitor's real address. See [proxy integrations](https://docs.fingerly.io/docs/proxy-integrations). ### Allowed origin A scheme, host and optional port a public key is accepted from in browsers, such as `https://shop.example.com`. Matched exactly. ### Idempotency key The `Idempotency-Key` header. Sent with every retry of one identify request so it is answered and charged once. See [idempotency](https://docs.fingerly.io/reference/idempotency). ### Consent state `granted`, `pending` or `denied`. Until it is `granted`, an SDK collects and sends nothing. See [privacy and consent](https://docs.fingerly.io/docs/privacy-and-consent#consent). ### Micros Millionths of a US dollar, the unit of `Fingerly-Balance-Micros` and billing webhooks. `3000` micros is $0.003. ## Webhooks ### Webhook endpoint An HTTPS URL on your server that Fingerly sends events to, listening to `live` or `test` traffic. See [webhooks](https://docs.fingerly.io/docs/webhooks). ### Envelope The JSON shape every webhook shares: `id`, `type`, `version`, `organization_id`, `environment`, `created_at` and `data`. ### Signing secret `whsec_…`. The per-endpoint secret each delivery is signed with. Verify the signature before you trust a delivery. --- # Changelog > Changes to the Fingerly API, webhook events, signals and every SDK, newest first, with the version each change arrived in. Every change you might notice is recorded here: in the API, in webhook events, in signals and their default weights, and in each SDK. Entries are newest first. Deprecations are announced here too, under the [versioning policy](https://docs.fingerly.io/docs/versioning). ## Current versions | Component | Version | | --- | --- | | [HTTP API](https://docs.fingerly.io/reference/overview) | `v1` | | [Webhook envelope](https://docs.fingerly.io/reference/webhooks/envelope) | `1` | | [JavaScript](https://docs.fingerly.io/docs/sdks/javascript), [React](https://docs.fingerly.io/docs/sdks/react), [Next.js](https://docs.fingerly.io/docs/sdks/nextjs), [Vue](https://docs.fingerly.io/docs/sdks/vue), [Nuxt](https://docs.fingerly.io/docs/sdks/nuxt), [Svelte](https://docs.fingerly.io/docs/sdks/svelte), [Angular](https://docs.fingerly.io/docs/sdks/angular) | 0.1.0 | | [iOS](https://docs.fingerly.io/docs/sdks/ios), [Android](https://docs.fingerly.io/docs/sdks/android), [React Native](https://docs.fingerly.io/docs/sdks/react-native), [Flutter](https://docs.fingerly.io/docs/sdks/flutter), [Lynx](https://docs.fingerly.io/docs/sdks/lynx) | 0.1.0 | | [Node.js](https://docs.fingerly.io/docs/sdks/node), [Python](https://docs.fingerly.io/docs/sdks/python), [Go](https://docs.fingerly.io/docs/sdks/go), [Java](https://docs.fingerly.io/docs/sdks/java), [.NET](https://docs.fingerly.io/docs/sdks/dotnet), [PHP](https://docs.fingerly.io/docs/sdks/php), [Ruby](https://docs.fingerly.io/docs/sdks/ruby), [Rust](https://docs.fingerly.io/docs/sdks/rust) | 0.1.0 | | [Cloudflare Worker proxy](https://docs.fingerly.io/docs/sdks/cloudflare-worker) | 0.1.0 | > **Tip:** Events record the SDK that sent each identification as `sdk_platform` and `sdk_version`. **Insights > Traffic** shows which versions are still in use. ## API ### v1 The first version of the API. - Client API: [`POST /identify`](https://docs.fingerly.io/reference/identify), [`POST /events/{request_id}/supplement`](https://docs.fingerly.io/reference/deferred-report) and [`POST /attestation/challenge`](https://docs.fingerly.io/reference/attestation-challenge), with public keys. - Server API: [`GET /events`](https://docs.fingerly.io/reference/list-events) and [`GET /events/{request_id}`](https://docs.fingerly.io/reference/get-event), with secret keys. - Proxy keys and forwarded visitor headers for first-party proxies. - `Idempotency-Key` on identify, and `RateLimit-Limit`, `RateLimit-Remaining`, `Retry-After` and `Fingerly-Balance-Micros` response headers, readable from browsers. - Management API: SDK keys, proxy keys, webhook endpoints and risk weights, with management keys acting as `admin` or `developer`. - Secret keys are refused from browsers on event reads as on identify, except development keys from the Try it panel on these docs. - An [OpenAPI 3.1 document and a Postman collection](https://docs.fingerly.io/reference/openapi). ## Webhooks ### Envelope version 1 The first version of webhook events. - Events: `identification.completed`, `visitor.suspect`, `identification.refused`, `billing.status_changed` and `usage.daily_settled`, and `webhook.test` from **Send test event**. - HMAC-SHA256 signatures over the timestamp and the body, with secret rotation and an overlap window of up to seven days. - Retries over about seven hours, redelivery from the delivery history, and pausing. ## Signals ### Initial signal groups The signal groups in the [signals reference](https://docs.fingerly.io/docs/signals), with their default weights for web, Android and iOS, and a default threshold of `30`. ## Web SDKs ### 0.1.0 The first version of `@fingerly/web-js` and the React, Next.js, Vue, Nuxt, Svelte and Angular bindings. - `identify()` with tags, a 300 ms initial collection and a deferred tier sent after the answer. - Local verdicts, `submit: false` and `collect()`. - First-party `endpoints`, with `fallbackToDefaultEndpoint`. - Consent states: `consent`, `setConsent()`, `onConsentChange()` and `ConsentError`. - Retries with one idempotency key, and `TransportError`. ## Mobile SDKs ### 0.1.0 The first version of the iOS and Android SDKs, and of the React Native, Flutter and Lynx packages over them. - `identify` with tags, local verdicts, `submit: false` and `collect`. - Hardware-backed attestation on Android 7.0 and newer. - Consent states and `setConsent`. ## Server SDKs ### 0.1.0 The first version of the Node.js, Python, Go, Java, .NET, PHP, Ruby and Rust libraries. - Read one event, and list events by window, visitor and level. - Webhook signature verification, accepting any signature during a secret rotation. - Node.js: `createProxy`, a first-party proxy for web `Request` and `Response` runtimes. ## Cloudflare Worker proxy ### 0.1.0 The first version of the Worker proxy: forwards identify and deferred-report requests for allowed origins, authenticated with a proxy key. --- # Versioning and support > How the API, webhooks, signals and SDKs are versioned, what counts as a breaking change, how long old versions are supported, and how deprecations are announced. An integration should keep working without changes until you choose to upgrade it. This page describes what Fingerly may change without notice, what it will not, and how long older versions are supported. ## The API The version is part of the path: `/api/v1`. Within a version, changes are additive. | Within a version, Fingerly may | Within a version, Fingerly will not | | --- | --- | | Add endpoints. | Remove or rename an endpoint or a field. | | Add optional request fields and headers. | Make an optional request field required. | | Add response fields and headers. | Change a field's type or what it means. | | Add values to fields that list them, such as a new `anonymity_network` or error code. | Change how requests are authenticated. | | Add signals and signal groups to `triggers`. | Change what an existing error code means. | A change that is not additive ships only in a new version, such as `/api/v2`. The previous version stays available for at least 12 months after the new one is released. > **Tip:** Write clients that ignore fields, values, signals and event types they do not know. Every Fingerly SDK already does. ## Webhooks Webhook events follow the same rules, versioned by the envelope's `version` field. - New fields may be added to the envelope and to `data`. - New event types are sent only to endpoints that subscribe to them. - A change that is not additive ships under a new envelope version, and the previous version is kept for at least 12 months. - The signature scheme does not change within a version. ## Signals - **Names are permanent.** A signal's name never changes meaning. If what a detection measures changes, it becomes a new signal with a new name, and the old name is never reused. - **New signals may appear** at any time, with a default weight. A new signal adds to the score of organizations that use default weights for it. - **Default weights and the default threshold** can change. Changes are announced in the [changelog](https://docs.fingerly.io/docs/changelog) before they apply. Weights you set yourself are never changed. ## SDKs Every SDK follows [semantic versioning](https://semver.org) on its own schedule, and every current SDK targets API `v1`. | Release | May contain | | --- | --- | | Patch, such as 1.2.3 to 1.2.4 | Fixes only. | | Minor, such as 1.2.0 to 1.3.0 | New features and new signals, without breaking changes. | | Major, such as 1.0.0 to 2.0.0 | Breaking changes, including a higher minimum platform version. | > **Note:** The SDKs are at 0.1. Until an SDK reaches 1.0, a minor release can contain breaking changes, as semantic versioning allows. Every one is listed in the changelog with what to change. ### Support windows | Version | Support | | --- | --- | | The latest major version | New features, fixes and security fixes. | | The previous major version | Security fixes and critical fixes for 12 months after the next major version is released. | | Older versions | Keep working for as long as the API version they target is supported, without fixes. | Platform minimums, such as the oldest iOS or Node.js version an SDK runs on, are listed in [browser and device support](https://docs.fingerly.io/docs/browser-and-device-support). Raising one is a breaking change. ## Deprecations - Every deprecation is announced in the [changelog](https://docs.fingerly.io/docs/changelog), with the date after which the deprecated version, endpoint or field may be removed. - Deprecated features are marked on their pages in these docs from the day they are deprecated. - Nothing is removed before the end of its support window. Security fixes are the exception: when a vulnerability can only be closed by a change that is not additive, Fingerly makes it as soon as it must, and says so in the changelog. ## Staying current - Pin SDK versions in your lockfile, and upgrade deliberately. - Read the changelog before a major upgrade. - Check **Insights > Traffic** for SDK versions still sending identifications before you retire an old app release. --- # Choosing an SDK > Fingerly SDKs for the browser, six web frameworks, iOS, Android, React Native, Flutter, Lynx, and eight server languages. Which to use, and how they fit together. A Fingerly integration has two halves. A **client SDK** runs where the visitor is, collects signals and identifies them with a public key. A **server SDK** runs on your backend and reads the result with a secret key, so decisions are made on data the visitor cannot edit. ## Web Every web SDK is built on the JavaScript SDK. The framework bindings share one identification per page and are safe under server rendering. - [JavaScript](https://docs.fingerly.io/docs/sdks/javascript): Any site, any framework - [React](https://docs.fingerly.io/docs/sdks/react): Provider and hooks - [Next.js](https://docs.fingerly.io/docs/sdks/nextjs): App Router and Server Actions - [Vue](https://docs.fingerly.io/docs/sdks/vue): Plugin and composables - [Nuxt](https://docs.fingerly.io/docs/sdks/nuxt): Module with auto-imports - [Svelte](https://docs.fingerly.io/docs/sdks/svelte): Svelte 5 and SvelteKit - [Angular](https://docs.fingerly.io/docs/sdks/angular): Providers and signals ## Mobile The native SDKs collect what only native code can reach. The cross-platform packages call them rather than collecting less. - [iOS](https://docs.fingerly.io/docs/sdks/ios): Swift, iOS 13+ - [Android](https://docs.fingerly.io/docs/sdks/android): Kotlin, API 21+ - [React Native](https://docs.fingerly.io/docs/sdks/react-native): Bridge to native - [Flutter](https://docs.fingerly.io/docs/sdks/flutter): Dart plugin - [Lynx](https://docs.fingerly.io/docs/sdks/lynx): Native module ## Server Server SDKs read events and verify webhooks, with the same calls in every language. Any other language can do both over [the HTTP API](https://docs.fingerly.io/reference/overview) with one request and one HMAC. - [Node.js](https://docs.fingerly.io/docs/sdks/node): Events, webhooks, proxy - [Python](https://docs.fingerly.io/docs/sdks/python): Sync and asyncio - [Go](https://docs.fingerly.io/docs/sdks/go): Context-aware client - [Java](https://docs.fingerly.io/docs/sdks/java): Java 11+, Kotlin, Scala - [.NET](https://docs.fingerly.io/docs/sdks/dotnet): C#, async, DI - [PHP](https://docs.fingerly.io/docs/sdks/php): PHP 8.1+ - [Ruby](https://docs.fingerly.io/docs/sdks/ruby): Ruby 3.1+, Rails - [Rust](https://docs.fingerly.io/docs/sdks/rust): Async, Tokio - [Cloudflare Worker](https://docs.fingerly.io/docs/sdks/cloudflare-worker): First-party proxy ## What every client SDK shares | Guarantee | What it means for you | | --- | --- | | No permission prompts | No dialog, picker or permission request on any platform, for any signal. | | A strict time budget | Collection stops at its budget. A slow or failing signal is recorded as such and never holds up the answer. | | Never crashes your app | Every signal source is isolated. One that throws cannot take the report or your app with it. | | One response shape | Browsers, iPhones and Android phones get the same fields back: request ID, visitor ID, score, level and triggers. | | Retries once, charged once | Network failures, `429` and `5xx` are retried with a single idempotency key. | | Advisory local verdicts | Verdicts computed on the device help your interface. The server's score is the one to act on. | ### Versions Every SDK follows semantic versioning on its own schedule and targets version `v1` of the API. SDKs report their version with every request, and events record it as `sdk_platform` and `sdk_version`, so the dashboard shows which builds are still in use. --- # JavaScript > Install the browser SDK, identify a visitor at the moments that matter, and send the request ID to your server. Works with any framework, or none. `@fingerly/web-js` is the browser SDK every web framework binding is built on. It collects device signals within a strict time budget, submits them with your public key, and resolves with the server's answer: the visitor ID, the suspect score and the signals behind it, in one round trip. > **Note:** Use a framework binding if you build with [React](https://docs.fingerly.io/docs/sdks/react), [Next.js](https://docs.fingerly.io/docs/sdks/nextjs), [Vue](https://docs.fingerly.io/docs/sdks/vue), [Nuxt](https://docs.fingerly.io/docs/sdks/nuxt), [Svelte](https://docs.fingerly.io/docs/sdks/svelte) or [Angular](https://docs.fingerly.io/docs/sdks/angular). They share one identification across the page, handle server rendering, and expose the same result as reactive state. ## Requirements - A browser that runs ES2020 with `fetch` and `AbortController`: every current version of Chrome, Edge, Firefox and Safari. - A [public key](https://docs.fingerly.io/docs/api-keys) whose allowed origins include the site the SDK runs on. - A secure context (HTTPS, or `localhost` during development). The SDK still runs without one, but several signals are only available to secure pages. ## Install ```bash npm npm install @fingerly/web-js ``` ```bash pnpm pnpm add @fingerly/web-js ``` ```bash yarn yarn add @fingerly/web-js ``` The package ships ES module and CommonJS builds with TypeScript declarations. It has no dependencies of its own at runtime. ### Script tag The package also includes a minified script-tag build, `dist/fingerly.global.js`, which defines a global `Fingerly` object. Serve the file from your own domain and load it before your code. ```html index.html ``` ## Identify a visitor ### Step 1: Load the agent once Call `load()` with your public key when your application starts. Keep the returned client and reuse it for every identification. ```ts fingerly.ts import { load } from '@fingerly/web-js' export const fingerly = await load({ apiKey: 'fly_pk_us_production_…' }) ``` The key decides where requests go: a `fly_pk_us_…` key talks to `https://us.api.fingerly.io`. Loading collects nothing and sends nothing. ### Step 2: Identify at the moment that matters Call `identify()` when the visitor does something worth protecting: signing up, logging in, checking out. Pass a `tag` that names the action, so your server can check the identification belongs to it. ```ts checkout.ts import { fingerly } from './fingerly' async function onCheckout(orderId: string) { const { requestId } = await fingerly.identify({ tag: 'checkout:' + orderId }) await fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orderId, requestId }), }) } ``` ### Step 3: Decide on your server Your backend reads the stored event by its request ID with a secret key, checks the tag and the time, and makes the decision. A result the browser reports can be edited by whoever controls the browser; the stored event cannot. Continue with [server-side verification](https://docs.fingerly.io/docs/server-side-verification). ## What identify returns `identify()` resolves once the server has answered, typically within a few hundred milliseconds. The initial collection is capped at 300 ms. ```ts result const result = await fingerly.identify({ tag: 'login' }) result.requestId // '01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4' result.visitorId // 'X9pL2mRc7KvT4bQw8NdF' result.visitorIsNew // false result.visitorConfidence // 100 result.identifiable // true result.suspectScore // 37 result.suspectLevel // 'high' result.triggers // [{ signal: 'tor', group: 'tor', weight: 14, confidence: 'high' }, …] result.verdicts // local, advisory verdicts result.deferred // a promise for the second collection tier ``` Every field is described in the [JavaScript agent reference](https://docs.fingerly.io/reference/javascript-agent#identifyresult). ## Tags A tag is your own reference for an identification, echoed back on the stored event, in webhooks and in the dashboard. Use it to bind an identification to the action it was made for, such as `checkout:8412` or `login`, and compare it on your server before you trust the result. ## The deferred tier Some signals take longer to read than the 300 ms initial budget allows. After the server has answered, the SDK keeps collecting them for up to 1,200 ms more and sends them as a deferred report attached to the same request. The deferred report is archived with the event. It never changes the visitor ID, the score, what you are charged or which webhooks fire. ```ts deferred.ts const result = await fingerly.identify({ tag: 'login' }) // Optional: observe the second tier when it finishes. const deferred = await result.deferred if (deferred.status === 'submitted') { console.log(deferred.verdicts) // verdicts over both tiers } ``` `result.deferred` never rejects. It resolves with `status: 'submitted'`, `'skipped'` or `'failed'`. ## Collect without sending Pass `submit: false` to collect and compute local verdicts without contacting the server. Nothing is scored, so `suspectScore` is `null` and `requestId` is empty. `collect()` returns just the report. ```ts local.ts const local = await fingerly.identify({ submit: false }) local.suspectScore // null: nobody scored it local.verdicts // computed in the browser const report = await fingerly.collect() ``` ## Serve from your own domain Content blockers often refuse requests to third-party API hosts. Route the SDK through a path on your own site with `endpoints`. The SDK appends `/api/v1/identify` to each base you give it. ```ts proxy.ts const fingerly = await load({ apiKey: 'fly_pk_us_production_…', endpoints: '/metrics', // your proxy's path fallbackToDefaultEndpoint: true, // optional: try the regional API last }) ``` The path needs a proxy behind it. See [proxy integrations](https://docs.fingerly.io/docs/proxy-integrations). ## Handle errors `identify()` rejects with a `TransportError` when the request cannot be completed. Branch on `status` and `retryable`. It rejects with a `ConsentError` when you loaded the SDK with a [consent state](https://docs.fingerly.io/docs/privacy-and-consent#consent) other than `granted`. ```ts errors.ts import { TransportError } from '@fingerly/web-js' try { await fingerly.identify({ tag: 'signup' }) } catch (error) { if (error instanceof TransportError) { if (error.status === 401) { // Wrong key, or this origin is not in the key's allowed origins. } else if (error.status === 402) { // The organization is not accepting traffic: add funds. } else if (error.retryable) { // Network trouble that outlasted the SDK's own retries. } } // Let the visitor continue: decide on the server without a request ID. } ``` - The SDK already retries network failures, timeouts, `429` and `5xx` up to three attempts, with jittered backoff and one idempotency key, so a retried request is answered and charged once. - Each attempt times out after 5 seconds. - `load()` throws synchronously for a configuration mistake, such as a missing `apiKey` or passing both `endpoint` and `endpoints`. Await it inside `try`. > **Warning:** Never block a visitor only because identification failed. Treat a missing request ID as missing evidence, and let your server decide what that means for the action. ## Next steps - [JavaScript agent reference](https://docs.fingerly.io/reference/javascript-agent): Every option, field and error. - [Server-side verification](https://docs.fingerly.io/docs/server-side-verification): Read the result with a secret key. - [Client-side verdicts](https://docs.fingerly.io/docs/client-verdicts): What the local verdicts mean. - [Content Security Policy](https://docs.fingerly.io/docs/content-security-policy): What to allow for the SDK. --- # React > A provider and two hooks for React 19. One shared identification per page, server-rendering safe, with the result as plain state. `@fingerly/react` wraps the [JavaScript SDK](https://docs.fingerly.io/docs/sdks/javascript) in a provider and hooks. However many components ask, a page makes one identification and every component reads the same answer. ## Requirements - React 19 or newer. - A [public key](https://docs.fingerly.io/docs/api-keys) with your site in its allowed origins. > **Note:** Building with Next.js? Use [`@fingerly/next`](https://docs.fingerly.io/docs/sdks/nextjs), which adds a Server Component friendly provider and helpers for Server Actions. ## Install ```bash npm npm install @fingerly/react ``` ```bash pnpm pnpm add @fingerly/react ``` ```bash yarn yarn add @fingerly/react ``` ## Add the provider ```tsx main.tsx import { createRoot } from 'react-dom/client' import { FingerlyProvider } from '@fingerly/react' createRoot(document.getElementById('root')!).render( , ) ``` The provider collects nothing when it mounts. It accepts `apiKey`, `endpoint`, `endpoints`, `fallbackToDefaultEndpoint`, `submit` and `consent`, with the meanings the [JavaScript agent](https://docs.fingerly.io/reference/javascript-agent#load) gives them. Changing `consent` takes effect without remounting, and `setConsent()` is also on the store from `useFingerly()`. ## Identify on an action ```tsx Checkout.tsx import { useIdentify } from '@fingerly/react' export function Checkout({ orderId }: { orderId: string }) { const { identify, isLoading, error } = useIdentify({ tag: () => 'checkout:' + orderId }) async function onSubmit() { const { requestId } = await identify() await submitOrder({ orderId, requestId }) } return ( <> {error &&

{error.message}

} ) } ``` ## Identify on mount ```tsx RiskBanner.tsx const { suspectLevel, isReady } = useIdentify({ immediate: true }) ``` `immediate` runs in an effect, so it never runs during server rendering. `suspend: true` suspends the component instead, for use inside ``. ## useIdentify **Options** - `immediate` (boolean, default `false`): Identify when the component mounts. - `suspend` (boolean, default `false`): Suspend during render until the identification finishes. - `tag` (string | () => string | undefined): The tag to send. A function is read at the moment of submission. A `tag` passed to `identify()` overrides it. **Returns** - `requestId` (string | null): Identifies this identification. Send it to your server with the action it protects. - `visitorId` (string | null): The stable identifier the server resolved for this browser. - `visitorIsNew` (boolean): Whether your organization is seeing this visitor for the first time. - `visitorConfidence` (number): How sure the identification is, from 0 to 100. - `identifiable` (boolean): `false` when the browser gave too little to identify anyone. - `duplicate` (boolean): `true` when the server had already answered this request. - `state` (string | null): `enriched`, or `unavailable` when the network lookup could not run. - `suspectScore` (number | null): The server's weighted score. `null` when nothing was scored, which is not the same as `0`. - `suspectLevel` (string | null): `low`, `medium` or `high`. - `triggers` (IdentifyTrigger[]): The signals the server scored, heaviest first. - `verdicts` (Verdicts | null): The local, advisory [client-side verdicts](https://docs.fingerly.io/docs/client-verdicts). - `error` (Error | null): The last failure, if the identification failed. - `isLoading` (boolean): An identification is in flight. - `isReady` (boolean): The identification has finished, successfully or not. - `isSuspicious` (boolean): Any local verdict matched at `medium` confidence or above. - `identify` ((options?: { tag?: string; force?: boolean }) => Promise): Identify, or return the page's existing identification. `force` collects afresh. - `refresh` ((options?: { tag?: string }) => Promise): Identify again, ignoring the shared result. `identify` and `refresh` are stable across renders. A failed identification is forgotten, so the next call tries again. ## useVerdict Reads one local verdict, with a confidence floor. It never triggers identification itself. ```tsx AutomationNotice.tsx import { useVerdict } from '@fingerly/react' function AutomationNotice() { const { matched, confidence } = useVerdict('automation', { min: 'high' }) if (!matched) return null return } ``` Returns `matched`, `verdict`, `confidence` and `reasons`. `min` defaults to `'medium'`. Verdict names are `incognito`, `shields`, `tor`, `emulator`, `automation` and `farm`. > **Warning:** Local verdicts are advisory and can be edited by whoever controls the browser. Make decisions on your server with the [stored event](https://docs.fingerly.io/docs/server-side-verification). ## Server rendering - On the server the hooks return the unidentified state, so hydration always matches. - Calling `identify()` during server rendering rejects with `FingerlyServerError`. Call it from an effect or an event handler. - For a server that renders many requests, create a store per request with `createFingerlyStore(options)` and pass it as ``. ## Errors Failures are stored in `error` and rethrown from `identify()`. `TransportError` is re-exported with its `status` and `retryable` fields. Using a hook outside a provider throws an error that says where to add one. --- # Next.js > Identify visitors in Next.js 15 and newer. A provider that works from a Server Component layout, a hidden form field, and a Server Action helper that reads it. `@fingerly/next` builds on [`@fingerly/react`](https://docs.fingerly.io/docs/sdks/react) for the App Router and the Pages Router. It adds a provider you can render from a Server Component, a form field that carries the request ID, and a server entry point with helpers that never import React. ## Requirements - Next.js 15 or newer, with React 19. - A [public key](https://docs.fingerly.io/docs/api-keys) with your site in its allowed origins. ## Install ```bash npm npm install @fingerly/next ``` ```bash pnpm pnpm add @fingerly/next ``` ```bash yarn yarn add @fingerly/next ``` ## Add the provider Read the key in your root layout, which stays a Server Component, and pass it to the provider. ```tsx app/layout.tsx import { FingerlyProvider } from '@fingerly/next' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` The provider also reads `NEXT_PUBLIC_FINGERLY_API_KEY`, `NEXT_PUBLIC_FINGERLY_ENDPOINT` and `NEXT_PUBLIC_FINGERLY_ENDPOINTS` (a JSON array) when a prop is not given. Passing the key as a prop is the arrangement that always works. With the Pages Router, render the same provider in `_app.tsx`. ## Carry the request ID through a Server Action `FingerlyRequestId` renders a hidden input that fills itself once the identification finishes. `readRequestId` reads it back on the server. ```tsx app/signup/page.tsx import { FingerlyRequestId } from '@fingerly/next' import { signUp } from './actions' export default function Page() { return (
) } ``` ```ts app/signup/actions.ts 'use server' import { readRequestId } from '@fingerly/next/server' import { load } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) export async function signUp(formData: FormData) { const requestId = readRequestId(formData) // null when the form carried none if (!requestId) return { error: 'unverified' } const event = await fingerly.events.get(requestId) if (event.suspect_level === 'high') return { error: 'review' } // create the account } ``` - The field is named `fingerly_request_id` (`REQUEST_ID_FIELD`). Pass `name` to change it. - `readRequestId` returns `null` for a missing, empty or malformed value and never throws. - Several fields on one page still make one identification. ## Hooks `useIdentify`, `useVerdict` and `useFingerly` are re-exported from `@fingerly/react` and behave identically. See the [React SDK](https://docs.fingerly.io/docs/sdks/react#useidentify). ```tsx app/checkout/Checkout.tsx 'use client' import { useIdentify } from '@fingerly/next' export function Checkout() { const { identify, isLoading } = useIdentify() // … } ``` ## Entry points | Import | Runs in | Contains | | --- | --- | --- | | `@fingerly/next` | The browser | The provider, `FingerlyRequestId` and the hooks. A `'use client'` module. | | `@fingerly/next/server` | The server | `readRequestId`, `REQUEST_ID_FIELD` and `createFingerlyProxy`. No React. | > **Warning:** Import server helpers from `@fingerly/next/server` only. Importing them from the main entry inside a Server Action gives a client reference that fails at runtime. ## Proxy through a route handler `createFingerlyProxy` is the [Node.js SDK's](https://docs.fingerly.io/docs/sdks/node#serve-the-browser-sdk-from-your-domain) `createProxy`, re-exported. Mount it on a catch-all route and point the provider at it. ```ts app/metrics/[...path]/route.ts import { createFingerlyProxy } from '@fingerly/next/server' export const POST = createFingerlyProxy({ proxyKey: process.env.FINGERLY_PROXY_KEY!, prefix: '/metrics', clientIp: (request) => request.headers.get('x-real-ip') ?? '', }) ``` ```tsx app/layout.tsx ``` Resolve the client address only from a header your hosting platform sets. See [proxy integrations](https://docs.fingerly.io/docs/proxy-integrations). --- # Vue > A plugin and composables for Vue 3.3 and newer. One shared identification per app, readonly reactive state, and safe under server rendering. `@fingerly/vue` installs the [JavaScript SDK](https://docs.fingerly.io/docs/sdks/javascript) as a Vue plugin. Composables read one shared identification as readonly refs. ## Requirements - Vue 3.3 or newer. - A [public key](https://docs.fingerly.io/docs/api-keys) with your site in its allowed origins. > **Note:** Using Nuxt? Install the [Nuxt module](https://docs.fingerly.io/docs/sdks/nuxt) instead. It registers this plugin and auto-imports the composables. ## Install ```bash npm npm install @fingerly/vue ``` ```bash pnpm pnpm add @fingerly/vue ``` ```bash yarn yarn add @fingerly/vue ``` ## Install the plugin ```ts main.ts import { createApp } from 'vue' import { createFingerly } from '@fingerly/vue' import App from './App.vue' createApp(App) .use(createFingerly({ apiKey: 'fly_pk_us_production_…' })) .mount('#app') ``` Installing collects nothing. `createFingerly` accepts `apiKey`, `endpoint`, `endpoints`, `fallbackToDefaultEndpoint`, `submit` and `consent`. Change consent at runtime with `useFingerly().setConsent()`. ## Identify on an action ```vue Checkout.vue ``` ## useIdentify **Options** - `immediate` (boolean, default `false`): Identify in `onMounted`. - `tag` (MaybeRefOrGetter): The tag to send, read at the moment of submission. Returns the same state as the [React hook](https://docs.fingerly.io/docs/sdks/react#useidentify), with every field a readonly ref and `isReady` and `isSuspicious` as computed refs, plus `identify({ tag?, force? })` and `refresh({ tag? })`. A call while an identification is in flight gets that identification, and a finished result is reused until you call `refresh()`. A failure is not cached. ## useVerdict ```vue AutomationNotice.vue ``` Both arguments accept refs and getters. Verdict names are `incognito`, `shields`, `tor`, `emulator`, `automation` and `farm`. ## Options API ```vue Legacy.vue ``` ## Server rendering `immediate` runs in `onMounted`, which never runs on the server. Calling `identify()` during server rendering rejects with `FingerlyServerError`. In a custom SSR setup, create the plugin per app instance rather than at module scope. --- # Nuxt > A Nuxt module for Nuxt 3 and 4. Configure the key in nuxt.config or the environment, then use auto-imported composables in any component. `@fingerly/nuxt` registers the [Vue plugin](https://docs.fingerly.io/docs/sdks/vue) and auto-imports `useIdentify`, `useVerdict` and `useFingerly`. ## Install ```bash npm npm install @fingerly/nuxt ``` ```bash pnpm pnpm add @fingerly/nuxt ``` ```bash yarn yarn add @fingerly/nuxt ``` ## Configure ```ts nuxt.config.ts export default defineNuxtConfig({ modules: ['@fingerly/nuxt'], fingerly: { apiKey: 'fly_pk_us_development_…', }, }) ``` The options are published to `runtimeConfig.public.fingerly`, so each one can be overridden per environment without a rebuild: ```bash .env NUXT_PUBLIC_FINGERLY_API_KEY=fly_pk_us_production_… NUXT_PUBLIC_FINGERLY_ENDPOINT= NUXT_PUBLIC_FINGERLY_SUBMIT=true NUXT_PUBLIC_FINGERLY_CONSENT=granted ``` **Module options** - `apiKey` (string, required): Your public key. - `endpoint` (string): A single API origin or first-party path. - `endpoints` (string[]): Ordered first-party endpoints. Set `endpoint` or `endpoints`, not both. - `fallbackToDefaultEndpoint` (boolean, default `false`): Try the regional API after your own endpoints fail. - `submit` (boolean, default `true`): Set `false` to collect without sending. - `consent` ('granted' | 'pending' | 'denied', default `'granted'`): The starting [consent state](https://docs.fingerly.io/docs/privacy-and-consent#consent). Change it at runtime with `setConsent()` from `useFingerly()`. > **Tip:** Without a key the module warns once in the console and identification fails until one is set. ## Use it ```vue components/SignupForm.vue ``` The composables are the Vue SDK's, unchanged. See [useIdentify](https://docs.fingerly.io/docs/sdks/vue#useidentify) and [useVerdict](https://docs.fingerly.io/docs/sdks/vue#useverdict). ## Server rendering The plugin is registered on both server and client, so the composables work in any component during server rendering, where they return the unidentified state. Identification itself only happens in the browser. ## Verify in a server route ```ts server/api/signup.post.ts import { load } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) export default defineEventHandler(async (event) => { const { email, requestId } = await readBody(event) const result = await fingerly.events.get(requestId) if (result.tag !== 'signup') throw createError({ statusCode: 400 }) // decide with result.suspect_level }) ``` --- # Svelte > Identify visitors in Svelte 5 and SvelteKit. Context-based setup, one shared identification per component tree, and reactive getters. `@fingerly/svelte` exposes the [JavaScript SDK](https://docs.fingerly.io/docs/sdks/javascript) through Svelte 5 runes. State lives in component context, so SvelteKit gets one identification per request tree rather than a shared module singleton. ## Requirements - Svelte 5. The package ships `.svelte.js` modules that your Svelte build compiles. - A [public key](https://docs.fingerly.io/docs/api-keys) with your site in its allowed origins. ## Install ```bash npm npm install @fingerly/svelte ``` ```bash pnpm pnpm add @fingerly/svelte ``` ```bash yarn yarn add @fingerly/svelte ``` ## Set up the context ```svelte src/routes/+layout.svelte {@render children()} ``` Call `setupFingerly` during component initialisation, in a ` {#if fingerly.error}

{fingerly.error.message}

{/if} ``` ## useIdentify Accepts `immediate` (identify inside an `$effect`) and `tag` (a string or a getter). Returns reactive getters for the [shared state](https://docs.fingerly.io/docs/sdks/react#useidentify) plus `identify`, `refresh` and `client`. ## useVerdict ```svelte Notice.svelte {#if tor.matched}

This session arrives through Tor.

{/if} ``` The name and `min` accept values or getters, so they follow changing props. Verdict names are `incognito`, `shields`, `tor`, `emulator`, `automation` and `farm`. ## Server rendering `immediate` uses `$effect`, which does not run on the server. Calling `identify()` directly during server rendering rejects with `FingerlyServerError`. --- # Angular > Identify visitors in Angular 19 and newer with environment providers and signal-based injection functions. Zoneless and server-rendering safe. `@fingerly/angular` provides the [JavaScript SDK](https://docs.fingerly.io/docs/sdks/javascript) to your application and reads the shared identification as read-only signals. ## Requirements - Angular 19 or newer, with standalone bootstrap. - A [public key](https://docs.fingerly.io/docs/api-keys) with your site in its allowed origins. ## Install ```bash npm npm install @fingerly/angular ``` ```bash pnpm pnpm add @fingerly/angular ``` ```bash yarn yarn add @fingerly/angular ``` ## Provide it ```ts app.config.ts import { ApplicationConfig } from '@angular/core' import { provideFingerly } from '@fingerly/angular' export const appConfig: ApplicationConfig = { providers: [provideFingerly({ apiKey: 'fly_pk_us_production_…' })], } ``` Add it where the application or a route is bootstrapped, not in component providers. The state is created lazily, once per application. ## Identify on an action ```ts checkout.component.ts import { Component, input } from '@angular/core' import { injectIdentify } from '@fingerly/angular' @Component({ selector: 'app-checkout', template: ` @if (fingerly.error(); as failure) {

{{ failure.message }}

} `, }) export class CheckoutComponent { readonly orderId = input.required() readonly fingerly = injectIdentify({ tag: () => 'checkout:' + this.orderId() }) async submit() { const { requestId } = await this.fingerly.identify() await submitOrder(this.orderId(), requestId) } } ``` ## injectIdentify Call it in an injection context: a field initialiser, a constructor, or `runInInjectionContext`. Options are `immediate` (identify in `afterNextRender`) and `tag` (a string, a getter or a signal). Every state field is a `Signal`, including the computed `isReady` and `isSuspicious`. ## injectVerdict ```ts notice.component.ts readonly automation = injectVerdict('automation', { min: 'high' }) // template: @if (automation.matched()) { … } ``` Returns `matched`, `verdict`, `confidence` and `reasons` as signals. Verdict names are `incognito`, `shields`, `tor`, `emulator`, `automation` and `farm`. ## Server rendering `afterNextRender` callbacks never run on the server, and the state is inert without a window. Calling `identify()` during server rendering rejects with `FingerlyServerError`. --- # iOS > Identify iPhones and iPads with the native Swift SDK. No dependencies, no permission prompts, and jailbreak, simulator and tampering verdicts on the device. The iOS SDK is written in Swift with no third-party dependencies. It collects device signals within a time budget, submits them with your public key, and returns the server's answer with local verdicts. It never shows a permission prompt. ## Requirements - iOS 13 or newer (also tvOS 13 and Mac Catalyst 13). - Xcode 16 or newer. - A [public key](https://docs.fingerly.io/docs/api-keys). ## Install ```swift Swift Package Manager .package(url: "https://github.com/fingerly-io/sdk-ios.git", from: "0.1.0") ``` ```ruby CocoaPods pod 'Fingerly', '~> 0.1' ``` In Xcode, add the package from **File > Add Package Dependencies** and link the `Fingerly` product to your app target. ## Identify a device ```swift SignInViewModel.swift import Fingerly let fingerly = try await Fingerly.load(apiKey: "fly_pk_us_production_…") func signIn(email: String, password: String) async throws { let result = try await fingerly.identify(tag: "sign-in") try await api.signIn(email: email, password: password, requestId: result.requestId) } ``` Load once and keep the client for the life of the app. `identify` runs on Swift concurrency and never touches the main thread. Send `requestId` to your backend with the action, and decide there after reading the stored event with a secret key. See [server-side verification](https://docs.fingerly.io/docs/server-side-verification). ## Configuration **`Fingerly.load`** - `apiKey` (String, required): Your public key. Its prefix decides the regional API. - `endpoint` (String, default `""`): A custom API origin or first-party proxy. Empty uses the region in the key. - `schedule` (ScheduleOptions, default `budgetMs 1200`): The collection budget: `budgetMs`, `defaultSourceTimeoutMs`, `concurrency` and `tiers`. - `consent` (ConsentState, default `.granted`): `.granted`, `.pending` or `.denied`. Until it is `.granted`, `identify` and `collect` read and send nothing and throw `ConsentError`. Change it with `setConsent(_:)`. See [consent](https://docs.fingerly.io/docs/privacy-and-consent#consent). **`identify`** - `tag` (String?): Your own reference for this identification, such as `checkout:8412`. - `tiers` ([SourceTier], default `all`): Which collection tiers to run: `.fast`, `.deferred`, or both. - `submit` (Bool, default `true`): Set `false` to collect and compute verdicts without sending anything. ```swift Budget.swift let fingerly = try await Fingerly.load( apiKey: "fly_pk_us_production_…", schedule: ScheduleOptions(budgetMs: 800, concurrency: 4) ) // A screen that cannot wait: only the fast tier. let result = try await fingerly.identify(tiers: [.fast]) ``` ## The result **`IdentifyResult`** - `requestId` (String): Identifies this identification. Send it to your server with the action it protects. - `visitorId` (String): The stable identifier the server resolved for this device. - `visitorIsNew` (Bool): Whether your organization is seeing this visitor for the first time. - `visitorConfidence` (Int): How sure the identification is, from 0 to 100. - `identifiable` (Bool): `false` when the device gave too little to identify anyone. - `duplicate` (Bool): `true` when the server had already answered this request. - `state` (String): `enriched`, or `unavailable` when the network lookup could not run. - `suspectScore` (Int?): The server's weighted score. `nil` when nothing was scored, which is not the same as `0`. - `suspectLevel` (String?): `low`, `medium` or `high`. - `triggers` ([IdentifyTrigger]): The signals the server scored: `signal`, `group`, `weight` and `confidence`. - `verdicts` (Verdicts): Local, advisory verdicts computed on the device. - `report` (SignalReport): The report that was sent. ## Verdicts | Verdict | What it means | | --- | --- | | `jailbreak` | The device is jailbroken. | | `simulator` | The app is running in the iOS Simulator. | | `instrumentation` | An instrumentation toolkit is attached to the app. | | `mitm` | Something is intercepting the app's encrypted traffic. | | `automation` | A debugger or a UI test runner is driving the app. | | `tampering` | The app's code has been hooked or modified at runtime. | | `farm` | The device looks mass-provisioned or freshly reset. | Each verdict has `value`, `confidence` (`.low`, `.medium`, `.high`) and `reasons`. They are advisory. The server scores the same evidence with your [risk weights](https://docs.fingerly.io/docs/risk-weights). ```swift Verdicts.swift if result.verdicts.jailbreak.value, result.verdicts.jailbreak.confidence == .high { // add friction here, and let your server make the final decision } ``` ## Optional: jailbreak app checks No `Info.plist` usage description is required. To let the SDK check for well-known jailbreak apps, declare their URL schemes. The SDK only queries the schemes your app declares. ```xml Info.plist LSApplicationQueriesSchemes cydia sileo zbra filza ``` ## Errors `identify` and `collect` throw `ConsentError` when consent is not `.granted`. `identify` throws a `TransportError` with `message`, `status` and `retryable`. The SDK already retries network failures, `429` and `5xx` up to three attempts with one idempotency key. `401` means the key is wrong; `402` means your organization is not accepting traffic. ```swift Errors.swift do { let result = try await fingerly.identify(tag: "checkout") } catch let error as TransportError where error.status == 401 { // the key is wrong or revoked } catch { // proceed, and let your server treat the missing request ID as missing evidence } ``` ## Objective-C `FingerlyBridge` exposes `configure(apiKey:endpoint:platform:)`, `identify(tag:submit:completion:)` and `collect(completion:)` to Objective-C. Errors use the domain `io.fingerly.sdk` with the HTTP status as the code. --- # Android > Identify Android devices with the native Kotlin SDK. One dependency, two install-time permissions, hardware-backed attestation, and verdicts on the device. The Android SDK is written in Kotlin. Its only dependency is Kotlin coroutines, nothing from Google Play services, and the two permissions it declares are granted at install time without a prompt. ## Requirements - Android 5.0 (API 21) or newer. - Java 17 toolchain, Android Gradle Plugin 8.11 or newer. - A [public key](https://docs.fingerly.io/docs/api-keys). ## Install ```kotlin build.gradle.kts dependencies { implementation("io.fingerly:fingerly:0.1.0") } ``` ```groovy build.gradle dependencies { implementation 'io.fingerly:fingerly:0.1.0' } ``` The SDK's manifest merges into yours. It declares `INTERNET` and `ACCESS_NETWORK_STATE`, and a small `` list of package names it checks for. It never requests `QUERY_ALL_PACKAGES`. Its R8 rules are bundled. ## Identify a device ```kotlin SignInViewModel.kt import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import io.fingerly.sdk.Fingerly import io.fingerly.sdk.identify import kotlinx.coroutines.async import kotlinx.coroutines.launch class SignInViewModel(private val app: Application) : AndroidViewModel(app) { private val fingerly = viewModelScope.async { Fingerly.load(app, apiKey = "fly_pk_us_production_…") } fun signIn(email: String, password: String) = viewModelScope.launch { val result = fingerly.await().identify(tag = "sign-in") api.signIn(email, password, requestId = result.requestId) } } ``` `load` and `identify` are `suspend` functions. Collection runs on `Dispatchers.IO`. Hold one client for the life of the process. Send `requestId` to your backend with the action, and decide there after reading the stored event with a secret key. See [server-side verification](https://docs.fingerly.io/docs/server-side-verification). ## Configuration **`Fingerly.load`** - `context` (Context, required): Any context. The SDK keeps the application context. - `apiKey` (String, required): Your public key. Its prefix decides the regional API. - `endpoint` (String, default `""`): A custom API origin or first-party proxy. Empty uses the region in the key. - `schedule` (ScheduleOptions, default `budgetMs = 1200`): The collection budget: `budgetMs`, `defaultSourceTimeoutMs`, `concurrency` and `tiers`. - `consent` (ConsentState, default `ConsentState.GRANTED`): `GRANTED`, `PENDING` or `DENIED`. Until it is `GRANTED`, `identify` and `collect` read and send nothing and throw `ConsentException`. Change it with `setConsent()` from any thread. See [consent](https://docs.fingerly.io/docs/privacy-and-consent#consent). **`identify`** - `tag` (String?): Your own reference for this identification. - `tiers` (List, default `SourceTier.ALL`): `SourceTier.FAST`, `SourceTier.DEFERRED`, or both. - `submit` (Boolean, default `true`): Set `false` to collect and compute verdicts without sending anything. ## The result **`IdentifyResult`** - `requestId` (String): Identifies this identification. Send it to your server with the action it protects. - `visitorId` (String): The stable identifier the server resolved for this device. - `visitorIsNew` (Boolean): Whether your organization is seeing this visitor for the first time. - `visitorConfidence` (Int): How sure the identification is, from 0 to 100. - `identifiable` (Boolean): `false` when the device gave too little to identify anyone. - `duplicate` (Boolean): `true` when the server had already answered this request. - `state` (String): `enriched`, or `unavailable` when the network lookup could not run. - `suspectScore` (Int?): The server's weighted score. `null` when nothing was scored, which is not the same as `0`. - `suspectLevel` (String?): `low`, `medium` or `high`. - `triggers` (List): The signals the server scored: `signal`, `group`, `weight` and `confidence`. - `verdicts` (Verdicts): Local, advisory verdicts computed on the device. - `report` (SignalReport): The report that was sent. ## Verdicts | Verdict | What it means | | --- | --- | | `root` | The device is rooted. | | `emulator` | The app is running in an emulator or a virtualised Android. | | `appCloner` | The app is running inside a cloning framework. | | `instrumentation` | An instrumentation toolkit is attached to the app. | | `mitm` | Something is intercepting the app's encrypted traffic. | | `automation` | A debugger, ADB or a test runner is driving the app. | | `tampering` | Methods are hooked or the app's signature has changed. | | `farm` | The device looks mass-provisioned or freshly reset. | ```kotlin Verdicts.kt import io.fingerly.sdk.core.Confidence if (result.verdicts.root.value && result.verdicts.root.confidence == Confidence.HIGH) { // add friction here, and let your server make the final decision } ``` ## Hardware-backed attestation On Android 7.0 (API 24) and newer, the SDK requests a one-time challenge from Fingerly and has the device keystore attest to it. The server verifies the attestation, including that it was produced for your app, and a failed check raises the `tampering` signal. If the challenge cannot be fetched, the SDK attests to a challenge it generates itself and identification continues; that attestation cannot prove it is fresh. There is nothing to configure. See [mobile app attestation](https://docs.fingerly.io/docs/mobile-attestation). ## Errors `identify` throws `TransportError` with `status` and `retryable`. The SDK retries IO failures, `429` and `5xx` up to three attempts with one idempotency key. `identify` and `collect` throw `ConsentException` when consent is not `GRANTED`; otherwise `collect()` never throws. ## Java ```java SignInActivity.java FingerlyBridge.configure(context, "fly_pk_us_production_…", "", "android"); FingerlyBridge.identify("sign-in", true, new BridgeCallback() { @Override public void onSuccess(String json) { /* parse the result */ } @Override public void onError(String code, String message) { /* code is the HTTP status */ } }); ``` --- # React Native > Identify devices in React Native apps through the native iOS and Android SDKs, with one TypeScript API and the same verdicts on both platforms. `@fingerly/react-native` is a thin bridge to the native [iOS](https://docs.fingerly.io/docs/sdks/ios) and [Android](https://docs.fingerly.io/docs/sdks/android) SDKs. Almost every signal worth having on a phone is out of reach of JavaScript, so the bridge calls native code rather than collecting less. ## Requirements - React Native 0.74 or newer, including the New Architecture. - iOS 13 and Android 5.0 (API 21) or newer. - A development build. Expo Go cannot load native modules. ## Install ```bash npm npm install @fingerly/react-native cd ios && pod install ``` ```bash pnpm pnpm add @fingerly/react-native cd ios && pod install ``` ```bash yarn yarn add @fingerly/react-native cd ios && pod install ``` Autolinking adds the native modules. Rebuild the app after installing. ## Identify a device ```ts fingerly.ts import { load } from '@fingerly/react-native' const fingerly = await load({ apiKey: 'fly_pk_us_production_…' }) export async function signIn(email: string, password: string) { const { requestId } = await fingerly.identify({ tag: 'sign-in' }) return api.signIn({ email, password, requestId }) } ``` Send `requestId` to your backend with the action, and decide there after reading the stored event with a secret key. See [server-side verification](https://docs.fingerly.io/docs/server-side-verification). ## API **`load(options)`** - `apiKey` (string, required): Your public key. - `endpoint` (string): A custom API origin or first-party proxy. - `consent` ('granted' | 'pending' | 'denied', default `'granted'`): Until it is `granted`, `identify` and `collect` read and send nothing and throw `ConsentError`. The state is shared by the whole app, so pass it on every `load`. See [consent](https://docs.fingerly.io/docs/privacy-and-consent#consent). **`identify(options)`** - `tag` (string): Your own reference for this identification. - `submit` (boolean, default `true`): Set `false` to collect without sending. `identify` resolves with the same fields as the native SDKs: `requestId`, `visitorId`, `visitorIsNew`, `visitorConfidence`, `identifiable`, `duplicate`, `state`, `suspectScore` (`number | null`), `suspectLevel`, `triggers`, `verdicts` and `report`. `collect()` returns a report without submitting. `setConsent(state)` changes the consent state, and `consent` reads it. ## Verdicts | Verdict | Platform | What it means | | --- | --- | --- | | `instrumentation` | Both | An instrumentation toolkit is attached to the app. | | `mitm` | Both | Something is intercepting the app's encrypted traffic. | | `automation` | Both | A debugger or a test runner is driving the app. | | `tampering` | Both | The app's code has been hooked or modified. | | `farm` | Both | The device looks mass-provisioned or freshly reset. | | `jailbreak`, `simulator` | iOS | Jailbroken device; iOS Simulator. | | `root`, `emulator`, `appCloner` | Android | Rooted device; emulator; cloning framework. | Verdicts a platform cannot answer are returned as `{ value: false, confidence: 'low', reasons: [] }`, so the shape is the same on both. ## Errors ```ts errors.ts import { FingerlyError } from '@fingerly/react-native' try { await fingerly.identify() } catch (error) { if (error instanceof FingerlyError && error.status === 401) { // the key is wrong or revoked } } ``` Retries happen in the native layer. If the native module is missing, every call throws a `FingerlyError` that explains how to rebuild. Without consent, calls throw `ConsentError`, a `FingerlyError` whose `code` is `consent_required`. --- # Flutter > Identify devices in Flutter apps through the native iOS and Android SDKs, with a typed Dart API and the same verdicts on both platforms. `fingerly_flutter` is a plugin over the native [iOS](https://docs.fingerly.io/docs/sdks/ios) and [Android](https://docs.fingerly.io/docs/sdks/android) SDKs. Collection happens in native code; Dart gets typed results. ## Requirements - Flutter 3.10 or newer, Dart 3. - iOS 13 and Android 5.0 (API 21) or newer. ## Install ```bash Terminal flutter pub add fingerly_flutter ``` ```yaml pubspec.yaml dependencies: fingerly_flutter: ^0.1.0 ``` The iOS side supports both CocoaPods and Swift Package Manager. ## Identify a device ```dart lib/fingerly.dart import 'package:fingerly_flutter/fingerly.dart'; final fingerly = await Fingerly.load(apiKey: 'fly_pk_us_production_…'); Future signIn(String email, String password) async { final result = await fingerly.identify(tag: 'sign-in'); await api.signIn(email, password, requestId: result.requestId); } ``` Send `requestId` to your backend with the action, and decide there after reading the stored event with a secret key. See [server-side verification](https://docs.fingerly.io/docs/server-side-verification). ## API | Member | Returns | Notes | | --- | --- | --- | | `Fingerly.load({required String apiKey, String endpoint = '', ConsentState consent = ConsentState.granted})` | `Future` | An empty endpoint uses the region in the key. The consent state is shared by the whole app, so pass it on every `load`. | | `setConsent(ConsentState state)` | `Future` | Changes the [consent state](https://docs.fingerly.io/docs/privacy-and-consent#consent). `consent` reads it. Until it is `granted`, `identify` and `collect` throw `FingerlyConsentException`. | | `identify({String? tag, bool submit = true})` | `Future` | `suspectScore` is `int?` and `null` when nothing was scored. | | `collect()` | `Future` | Collects without submitting. | | `Fingerly.nativeVersion()` | `Future` | The native SDK version. | ## Verdicts | Verdict | Platform | What it means | | --- | --- | --- | | `instrumentation` | Both | An instrumentation toolkit is attached to the app. | | `mitm` | Both | Something is intercepting the app's encrypted traffic. | | `automation` | Both | A debugger or a test runner is driving the app. | | `tampering` | Both | The app's code has been hooked or modified. | | `farm` | Both | The device looks mass-provisioned or freshly reset. | | `jailbreak`, `simulator` | iOS | Jailbroken device; iOS Simulator. | | `root`, `emulator`, `appCloner` | Android | Rooted device; emulator; cloning framework. | ```dart verdicts.dart if (result.verdicts.jailbreak.value && result.verdicts.jailbreak.confidence == Confidence.high) { // add friction, and let your server make the final decision } ``` ## Errors ```dart errors.dart try { await fingerly.identify(); } on FingerlyException catch (error) { if (error.status == 401) { /* the key is wrong */ } if (error.status == 402) { /* the organization is not accepting traffic */ } } ``` > **Tip:** If you see a missing plugin error after installing, run `flutter clean` and rebuild the app rather than hot reloading. --- # Lynx > Identify devices in Lynx apps on iOS and Android through a native module over the Fingerly mobile SDKs. `@fingerly/lynx` is a Lynx native module over the [iOS](https://docs.fingerly.io/docs/sdks/ios) and [Android](https://docs.fingerly.io/docs/sdks/android) SDKs, with the same TypeScript API as [React Native](https://docs.fingerly.io/docs/sdks/react-native). ## Requirements - Lynx 4.1 on iOS 13 or Android 6.0 (API 23) or newer. - HarmonyOS, Web and Lynxtron hosts are not supported. ## Install ### Step 1: Add the package ```bash Terminal pnpm add @fingerly/lynx ``` ### Step 2: Enable Lynx Native Library Autolink On Android, apply `org.lynxsdk.library-settings` in `settings.gradle` and `org.lynxsdk.library-build` in your app module. On iOS, add the `cocoapods-lynx-library` plugin to your Podfile, call `use_lynx_library!`, then run `pod install`. ### Step 3: Rebuild the host app The module is registered at build time. ## Identify a device ```ts src/fingerly.ts 'background only' import { load } from '@fingerly/lynx' const fingerly = await load({ apiKey: 'fly_pk_us_production_…' }) const result = await fingerly.identify({ tag: 'checkout' }) // Send result.requestId to your backend and make the decision there. ``` > **Note:** Call the SDK from background-thread scripting. The module is marked `'background only'`. ## API `load({ apiKey, endpoint?, consent? })`, `identify({ tag?, submit? })`, `collect()`, `setConsent(state)` and `nativeVersion()` behave as in the [React Native SDK](https://docs.fingerly.io/docs/sdks/react-native#api). Errors are `FingerlyError` with an optional HTTP `status`; without consent they are `ConsentError`. ## Verdicts | Verdict | Platform | What it means | | --- | --- | --- | | `instrumentation` | Both | An instrumentation toolkit is attached to the app. | | `mitm` | Both | Something is intercepting the app's encrypted traffic. | | `automation` | Both | A debugger or a test runner is driving the app. | | `tampering` | Both | The app's code has been hooked or modified. | | `farm` | Both | The device looks mass-provisioned or freshly reset. | | `jailbreak`, `simulator` | iOS | Jailbroken device; iOS Simulator. | | `root`, `emulator`, `appCloner` | Android | Rooted device; emulator; cloning framework. | Verdicts a platform cannot answer are returned as `{ value: false, confidence: 'low', reasons: [] }`, so the shape is the same on both. --- # Node.js > Read identification events with a secret key, verify signed webhooks, and serve the browser SDK from your own domain with the Fingerly Node.js SDK. `@fingerly/node` does three things on your server: reads stored events by request ID, verifies webhook signatures, and forwards the browser SDK's requests through a route on your own domain. It has no runtime dependencies. ## Requirements - Node.js 18.16 or newer, or any runtime with global `fetch`, `Request` and `Response` (Bun, Deno, Cloudflare Workers, Vercel Functions). - A [secret key](https://docs.fingerly.io/docs/api-keys) for reading events, a webhook signing secret, and a [proxy key](https://docs.fingerly.io/docs/proxy-integrations) for the proxy. ## Install ```bash npm npm install @fingerly/node ``` ```bash pnpm pnpm add @fingerly/node ``` ```bash yarn yarn add @fingerly/node ``` ## Read an event Create one client with your secret key and reuse it. The key decides the regional API and the environment the client reads. ```ts Node.js import { load } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const event = await fingerly.events.get('01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4') ``` An event has the fields listed in [Get an event](https://docs.fingerly.io/reference/get-event#response). `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](https://docs.fingerly.io/docs/server-side-verification). ```ts Node.js import { load, FingerlyAPIError } from '@fingerly/node' const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! }) const MAX_AGE_MS = 2 * 60 * 1000 export async function decide(orderId: string, requestId: string) { let event try { event = await fingerly.events.get(requestId) } catch (error) { if (error instanceof FingerlyAPIError && error.status === 404) return 'refuse' throw error } if (event.tag !== 'checkout:' + orderId) return 'refuse' if (Date.now() - Date.parse(event.occurred_at) > MAX_AGE_MS) return 'refuse' if (event.suspect_level === 'high') return 'review' if (event.suspect_level === 'medium') return 'challenge' return '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. ```ts Node.js import express from 'express' import { verifyWebhook } from '@fingerly/node' app.post('/webhooks/fingerly', express.raw({ type: 'application/json' }), async (req, res) => { const valid = verifyWebhook({ secret: process.env.FINGERLY_WEBHOOK_SECRET!, payload: req.body, timestamp: req.get('x-fingerly-timestamp'), signature: req.get('x-fingerly-signature'), }) if (!valid) return res.sendStatus(400) const event = JSON.parse(req.body.toString('utf8')) await queue.add(event.id, event) // deduplicate on event.id res.sendStatus(204) }) ``` ## Serve the browser SDK from your domain `createProxy` returns a `(request: Request) => Promise` handler that forwards the browser SDK's identify and deferred-report requests to Fingerly with the visitor's real address, origin and user agent. Mount it on any framework that speaks web `Request` and `Response`. ```ts Next.js // app/metrics/[...path]/route.ts import { createProxy } from '@fingerly/node' export const POST = createProxy({ proxyKey: process.env.FINGERLY_PROXY_KEY!, // fly_px_us_production_… prefix: '/metrics', clientIp: (request) => request.headers.get('x-real-ip') ?? '', }) ``` ```ts Hono import { Hono } from 'hono' import { createProxy } from '@fingerly/node' const proxy = createProxy({ proxyKey: process.env.FINGERLY_PROXY_KEY!, prefix: '/metrics', clientIp: (request) => request.headers.get('cf-connecting-ip') ?? '', }) const app = new Hono() app.post('/metrics/*', (c) => proxy(c.req.raw)) ``` ```ts Browser import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…', endpoints: '/metrics' }) ``` > **Warning:** Resolve `clientIp` from a header your own infrastructure sets, such as your load balancer's or CDN's. Never use the left-most `X-Forwarded-For` value: the visitor can write it. ## API **`load(options)`** - `secretKey` (string, required): A secret key. Its prefix decides the regional API. - `endpoint` (string): Override the API origin. - `fetchImpl` (typeof fetch, default `globalThis.fetch`): A custom `fetch`. | Method | Returns | Calls | | --- | --- | --- | | `events.list(query?)` | `Promise` | [`GET /events`](https://docs.fingerly.io/reference/list-events). `query` takes `from`, `to` (a `Date` or RFC 3339 string), `page`, `limit`, `visitor` and `level`, and an optional `signal` (an `AbortSignal`) to cancel the request. | | `events.get(requestId, signal?)` | `Promise` | [`GET /events/{request_id}`](https://docs.fingerly.io/reference/get-event). | A non-2xx response throws `FingerlyAPIError` with the HTTP `status`. Network errors and aborts are thrown as they are. **`verifyWebhook(options)`, returns `boolean`** - `secret` (string, required): The endpoint's signing secret, `whsec_…`. - `payload` (string | Uint8Array, required): The raw request body, byte for byte. - `timestamp` (string | null, required): The `x-fingerly-timestamp` header. - `signature` (string | null, required): The `x-fingerly-signature` header. - `toleranceSeconds` (number, default `300`): How far the timestamp may be from now. **`createProxy(options)`** - `proxyKey` (string, required): A proxy key, `fly_px_…`. Its prefix decides the regional API. - `clientIp` ((request: Request) => string | Promise, required): Resolves the visitor's address from infrastructure you trust. - `prefix` (string, default `'/api/fingerly'`): The path the proxy is mounted under. - `maxBodyBytes` (number, default `1048576`): Larger bodies are refused with `413`. - `timeoutMs` (number, default `5000`): How long to wait for Fingerly. - `upstream` (string): Override the API origin. The proxy answers `405` to anything but `POST`, `404` to any path except `{prefix}/api/v1/identify` and `{prefix}/api/v1/events/{request_id}/supplement`, and `401` when the request carries no public key. --- # Python > Read identification events with a secret key and verify signed webhooks from Python, with a synchronous client and an asyncio client that share one API. The `fingerly` package reads stored events by request ID and verifies webhook signatures. It ships two clients with the same methods: `Fingerly` for synchronous code such as Django and Flask, and `AsyncFingerly` for `asyncio` code such as FastAPI, Starlette and aiohttp. ## Requirements - Python 3.9 or newer. - A [secret key](https://docs.fingerly.io/docs/api-keys), and a webhook signing secret if you receive [webhooks](https://docs.fingerly.io/docs/webhooks). ## Install ```bash pip pip install fingerly ``` ```bash uv uv add fingerly ``` ```bash Poetry poetry add fingerly ``` ## Read an event Create one client with your secret key and reuse it. The key decides the regional API and the environment the client reads. ```python Python import os from fingerly import Fingerly fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```python Python (async) import os from fingerly import AsyncFingerly fingerly = AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) event = await fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` An event has the fields listed in [Get an event](https://docs.fingerly.io/reference/get-event#response). `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](https://docs.fingerly.io/docs/server-side-verification). ```python Python from datetime import datetime, timedelta, timezone from fingerly import Fingerly, FingerlyAPIError fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) def decide(order_id: str, request_id: str) -> str: try: event = fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404: return "refuse" raise if event.tag != f"checkout:{order_id}": return "refuse" if datetime.now(timezone.utc) - event.occurred_at > timedelta(minutes=2): return "refuse" if event.suspect_level == "high": return "review" if event.suspect_level == "medium": return "challenge" return "allow" ``` ```python Python (async) from datetime import datetime, timedelta, timezone from fingerly import AsyncFingerly, FingerlyAPIError fingerly = AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) async def decide(order_id: str, request_id: str) -> str: try: event = await fingerly.events.get(request_id) except FingerlyAPIError as error: if error.status == 404: return "refuse" raise if event.tag != f"checkout:{order_id}": return "refuse" if datetime.now(timezone.utc) - event.occurred_at > timedelta(minutes=2): return "refuse" return {"high": "review", "medium": "challenge"}.get(event.suspect_level, "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. ```python Python from flask import Flask, abort, request from fingerly import verify_webhook @app.post("/webhooks/fingerly") def fingerly_webhook(): payload = request.get_data() if not verify_webhook( secret=os.environ["FINGERLY_WEBHOOK_SECRET"], payload=payload, timestamp=request.headers.get("x-fingerly-timestamp"), signature=request.headers.get("x-fingerly-signature"), ): abort(400) event = json.loads(payload) queue.enqueue(event["id"], event) # deduplicate on the event ID return "", 204 ``` ```python Python (async) from fastapi import FastAPI, HTTPException, Request, Response from fingerly import verify_webhook @app.post("/webhooks/fingerly", status_code=204) async def fingerly_webhook(request: Request) -> Response: payload = await request.body() if not verify_webhook( secret=os.environ["FINGERLY_WEBHOOK_SECRET"], payload=payload, timestamp=request.headers.get("x-fingerly-timestamp"), signature=request.headers.get("x-fingerly-signature"), ): raise HTTPException(status_code=400) event = json.loads(payload) await queue.enqueue(event["id"], event) return Response(status_code=204) ``` ## Sync or async | | `Fingerly` | `AsyncFingerly` | | --- | --- | --- | | Use in | Django, Flask, scripts, Celery tasks | FastAPI, Starlette, aiohttp, Quart | | Calls | `fingerly.events.get(id)` | `await fingerly.events.get(id)` | | HTTP client | One pooled connection per client | One pooled connection per client, per event loop | | Closing | `fingerly.close()`, or `with Fingerly(...) as fingerly:` | `await fingerly.aclose()`, or `async with AsyncFingerly(...) as fingerly:` | ```python lifespan.py from contextlib import asynccontextmanager from fastapi import FastAPI from fingerly import AsyncFingerly @asynccontextmanager async def lifespan(app: FastAPI): async with AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) as fingerly: app.state.fingerly = fingerly yield app = FastAPI(lifespan=lifespan) ``` ## API | Member | Returns | Notes | | --- | --- | --- | | `Fingerly(secret_key, endpoint=None, timeout=10.0)` | client | The key's prefix decides the regional API. `AsyncFingerly` takes the same arguments. | | `events.get(request_id)` | `Event` | Raises `FingerlyAPIError` with `.status` for a non-2xx response. | | `events.list(from_=None, to=None, page=1, limit=10, visitor=None, level=None)` | `EventPage` | `EventPage` has `rows`, `page` and `page_size`. `from_` and `to` accept `datetime`. | | `verify_webhook(secret, payload, timestamp, signature, tolerance_seconds=300)` | `bool` | Synchronous in both clients. Never raises for bad input. | `Event` exposes every field of the event as an attribute, with `occurred_at` parsed to a timezone-aware `datetime`. --- # Go > Read identification events with a secret key and verify signed webhooks from Go, with context-aware calls and typed events. The Go module reads stored events by request ID and verifies webhook signatures. Every call takes a `context.Context`, and the client is safe for concurrent use. ## Requirements - Go 1.21 or newer. - A [secret key](https://docs.fingerly.io/docs/api-keys), and a webhook signing secret if you receive [webhooks](https://docs.fingerly.io/docs/webhooks). ## Install ```bash Terminal go get github.com/fingerly-io/fingerly-go ``` ## Read an event Create one client with your secret key and reuse it. The key decides the regional API and the environment the client reads. ```go Go import fingerly "github.com/fingerly-io/fingerly-go" client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY")) event, err := client.Events.Get(ctx, "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` An event has the fields listed in [Get an event](https://docs.fingerly.io/reference/get-event#response). `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](https://docs.fingerly.io/docs/server-side-verification). ```go Go func decide(ctx context.Context, orderID, requestID string) (string, error) { event, err := client.Events.Get(ctx, requestID) var apiErr *fingerly.APIError if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound { return "refuse", nil } else if err != nil { return "", err } if event.Tag != "checkout:"+orderID || time.Since(event.OccurredAt) > 2*time.Minute { return "refuse", nil } switch event.SuspectLevel { case "high": return "review", nil case "medium": return "challenge", nil } return "allow", nil } ``` ## Verify a webhook Check the signature over the raw request body before parsing it. The helper rejects timestamps more than five minutes from now. ```go Go func fingerlyWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "unreadable body", http.StatusBadRequest) return } if !fingerly.VerifyWebhook( os.Getenv("FINGERLY_WEBHOOK_SECRET"), body, r.Header.Get("X-Fingerly-Timestamp"), r.Header.Get("X-Fingerly-Signature"), ) { http.Error(w, "invalid signature", http.StatusBadRequest) return } var event fingerly.WebhookEvent _ = json.Unmarshal(body, &event) enqueue(event.ID, body) // deduplicate on the event ID w.WriteHeader(http.StatusNoContent) } ``` ## API | Member | Returns | Notes | | --- | --- | --- | | `fingerly.New(secretKey string, opts ...Option)` | `*Client` | Options: `WithEndpoint`, `WithHTTPClient`. | | `client.Events.Get(ctx, requestID)` | `(*Event, error)` | A non-2xx response returns `*fingerly.APIError` with `Status`. | | `client.Events.List(ctx, *EventListParams)` | `(*EventPage, error)` | Params: `From`, `To` (`time.Time`), `Page`, `Limit`, `Visitor`, `Level`. | | `fingerly.VerifyWebhook(secret, body, timestamp, signature)` | `bool` | Five minutes of tolerance. `VerifyWebhookWithTolerance` changes it. | --- # Java > Read identification events with a secret key and verify signed webhooks from Java 11 and newer, and from Kotlin and Scala on the JVM. The Java library reads stored events by request ID and verifies webhook signatures. It uses the JDK's `java.net.http` client, and the client is thread-safe: build one and share it. ## Requirements - Java 11 or newer. - A [secret key](https://docs.fingerly.io/docs/api-keys), and a webhook signing secret if you receive [webhooks](https://docs.fingerly.io/docs/webhooks). ## Install ```kotlin Gradle implementation("io.fingerly:fingerly-server:0.1.0") ``` ```xml Maven io.fingerly fingerly-server 0.1.0 ``` ## Read an event Create one client with your secret key and reuse it. The key decides the regional API and the environment the client reads. ```java Java import io.fingerly.server.FingerlyClient; FingerlyClient fingerly = FingerlyClient.builder() .secretKey(System.getenv("FINGERLY_SECRET_KEY")) .build(); Event event = fingerly.events().get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4"); ``` An event has the fields listed in [Get an event](https://docs.fingerly.io/reference/get-event#response). `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](https://docs.fingerly.io/docs/server-side-verification). ```java Java public String decide(String orderId, String requestId) { Event event; try { event = fingerly.events().get(requestId); } catch (FingerlyApiException e) { if (e.getStatus() == 404) return "refuse"; throw e; } if (!("checkout:" + orderId).equals(event.getTag())) return "refuse"; if (event.getOccurredAt().isBefore(Instant.now().minus(Duration.ofMinutes(2)))) return "refuse"; return switch (String.valueOf(event.getSuspectLevel())) { case "high" -> "review"; case "medium" -> "challenge"; default -> "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. ```java Java @PostMapping("/webhooks/fingerly") public ResponseEntity receive( @RequestBody byte[] body, @RequestHeader("x-fingerly-timestamp") String timestamp, @RequestHeader("x-fingerly-signature") String signature) { if (!Webhooks.verify(webhookSecret, body, timestamp, signature)) { return ResponseEntity.badRequest().build(); } WebhookEvent event = Webhooks.parse(body); events.enqueue(event.getId(), body); // deduplicate on the event ID return ResponseEntity.noContent().build(); } ``` > **Tip:** Receive webhook bodies as `byte[]` rather than a parsed object, so the signature is checked over the exact bytes Fingerly sent. ## API | Member | Returns | Notes | | --- | --- | --- | | `FingerlyClient.builder().secretKey(key).build()` | `FingerlyClient` | Also `.endpoint(url)` and `.httpClient(client)`. | | `events().get(requestId)` | `Event` | Throws `FingerlyApiException` with `getStatus()` for a non-2xx response. | | `events().list(EventListParams)` | `EventPage` | `EventListParams.builder()` takes `from`, `to` (`Instant`), `page`, `limit`, `visitor`, `level`. | | `Webhooks.verify(secret, body, timestamp, signature)` | `boolean` | Five minutes of tolerance. | --- # .NET > Read identification events with a secret key and verify signed webhooks from .NET 6 and newer, with async APIs and dependency injection. The `Fingerly` NuGet package reads stored events by request ID and verifies webhook signatures. Every call is asynchronous and accepts a `CancellationToken`. ## Requirements - .NET 6 or newer. - A [secret key](https://docs.fingerly.io/docs/api-keys), and a webhook signing secret if you receive [webhooks](https://docs.fingerly.io/docs/webhooks). ## Install ```bash .NET CLI dotnet add package Fingerly ``` ```xml PackageReference ``` ## Read an event Create one client with your secret key and reuse it. The key decides the regional API and the environment the client reads. ```csharp .NET 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](https://docs.fingerly.io/reference/get-event#response). `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](https://docs.fingerly.io/docs/server-side-verification). ```csharp .NET public async Task 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. ```csharp .NET app.MapPost("/webhooks/fingerly", async (HttpRequest request, IEventQueue queue) => { using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(); var valid = FingerlyWebhook.Verify( secret: builder.Configuration["Fingerly:WebhookSecret"]!, payload: body, timestamp: request.Headers["x-fingerly-timestamp"], signature: request.Headers["x-fingerly-signature"]); if (!valid) return Results.BadRequest(); var ev = FingerlyWebhook.Parse(body); await queue.EnqueueAsync(ev.Id, body); // deduplicate on the event ID return Results.NoContent(); }); ``` ## Dependency injection ```csharp Program.cs builder.Services.AddFingerly(options => { options.SecretKey = builder.Configuration["Fingerly:SecretKey"]; }); // Inject FingerlyClient wherever it is needed. It is registered as a singleton. ``` ## API | Member | Returns | Notes | | --- | --- | --- | | `new FingerlyClient(secretKey)` | `FingerlyClient` | Or `new FingerlyClient(new FingerlyClientOptions { ... })` for `Endpoint` and `HttpClient`. | | `Events.GetAsync(requestId, ct)` | `Task` | Throws `FingerlyApiException` with `Status` for a non-2xx response. | | `Events.ListAsync(EventListOptions, ct)` | `Task` | Options: `From`, `To` (`DateTimeOffset`), `Page`, `Limit`, `Visitor`, `Level`. | | `FingerlyWebhook.Verify(secret, payload, timestamp, signature)` | `bool` | Five minutes of tolerance. `FingerlyWebhook.Parse` reads the envelope. | Event properties are PascalCase: `ev.RequestId`, `ev.SuspectLevel`, `ev.OccurredAt`. --- # PHP > Read identification events with a secret key and verify signed webhooks from PHP 8.1 and newer, with Laravel and Symfony examples. The `fingerly/fingerly-php` package reads stored events by request ID and verifies webhook signatures. It uses any PSR-18 HTTP client and falls back to Guzzle. ## Requirements - PHP 8.1 or newer. - A [secret key](https://docs.fingerly.io/docs/api-keys), and a webhook signing secret if you receive [webhooks](https://docs.fingerly.io/docs/webhooks). ## Install ```bash Terminal composer require fingerly/fingerly-php ``` ## Read an event Create one client with your secret key and reuse it. The key decides the regional API and the environment the client reads. ```php PHP events->get('01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4'); ``` An event has the fields listed in [Get an event](https://docs.fingerly.io/reference/get-event#response). `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](https://docs.fingerly.io/docs/server-side-verification). ```php PHP events->get($requestId); } catch (ApiException $e) { if ($e->getStatus() === 404) { return 'refuse'; } throw $e; } if ($event->tag !== "checkout:{$orderId}") { return 'refuse'; } if ($event->occurredAt < new DateTimeImmutable('-2 minutes')) { return 'refuse'; } return match ($event->suspectLevel) { 'high' => 'review', 'medium' => 'challenge', default => '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. ```php PHP getContent(), timestamp: $request->header('x-fingerly-timestamp'), signature: $request->header('x-fingerly-signature'), ); abort_unless($valid, 400); ProcessFingerlyEvent::dispatch($request->json()->all()); return response()->noContent(); }); ``` > **Note:** Exclude the webhook route from CSRF verification. In Laravel, add it to the `except` list of the CSRF middleware. ## API | Member | Returns | Notes | | --- | --- | --- | | `new Client($secretKey)` | `Client` | A second argument takes `endpoint` and an HTTP client. | | `$client->events->get($requestId)` | `Event` | Throws `ApiException` with `getStatus()` for a non-2xx response. | | `$client->events->list([...])` | `EventPage` | Keys: `from`, `to`, `page`, `limit`, `visitor`, `level`. | | `Webhook::verify(secret:, payload:, timestamp:, signature:)` | `bool` | Five minutes of tolerance. | --- # Ruby > Read identification events with a secret key and verify signed webhooks from Ruby 3.1 and newer, with a Rails example. The `fingerly` gem reads stored events by request ID and verifies webhook signatures. It has no runtime dependencies beyond the standard library. ## Requirements - Ruby 3.1 or newer. - A [secret key](https://docs.fingerly.io/docs/api-keys), and a webhook signing secret if you receive [webhooks](https://docs.fingerly.io/docs/webhooks). ## Install ```bash Bundler bundle add fingerly ``` ```ruby Gemfile gem "fingerly", "~> 0.1" ``` ## Read an event Create one client with your secret key and reuse it. The key decides the regional API and the environment the client reads. ```ruby Ruby require "fingerly" fingerly = Fingerly::Client.new(secret_key: ENV.fetch("FINGERLY_SECRET_KEY")) event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` An event has the fields listed in [Get an event](https://docs.fingerly.io/reference/get-event#response). `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](https://docs.fingerly.io/docs/server-side-verification). ```ruby Ruby def decide(order_id, request_id) event = fingerly.events.get(request_id) return "refuse" unless event.tag == "checkout:#{order_id}" return "refuse" if event.occurred_at < Time.now - 120 case event.suspect_level when "high" then "review" when "medium" then "challenge" else "allow" end rescue Fingerly::APIError => e raise unless e.status == 404 "refuse" end ``` ## Verify a webhook Check the signature over the raw request body before parsing it. The helper rejects timestamps more than five minutes from now. ```ruby Ruby class FingerlyWebhooksController < ActionController::API def create payload = request.raw_post valid = Fingerly::Webhook.verify( secret: ENV.fetch("FINGERLY_WEBHOOK_SECRET"), payload: payload, timestamp: request.headers["x-fingerly-timestamp"], signature: request.headers["x-fingerly-signature"], ) return head :bad_request unless valid event = JSON.parse(payload) FingerlyEventJob.perform_later(event) # deduplicate on event["id"] head :no_content end end ``` ## Rails ```ruby config/initializers/fingerly.rb FINGERLY = Fingerly::Client.new(secret_key: Rails.application.credentials.dig(:fingerly, :secret_key)) ``` ## API | Member | Returns | Notes | | --- | --- | --- | | `Fingerly::Client.new(secret_key:, endpoint: nil, timeout: 10)` | client | Thread-safe; create one per process. | | `events.get(request_id)` | `Fingerly::Event` | Raises `Fingerly::APIError` with `#status` for a non-2xx response. | | `events.list(from: nil, to: nil, page: 1, limit: 10, visitor: nil, level: nil)` | `Fingerly::EventPage` | Has `rows`, `page` and `page_size`. | | `Fingerly::Webhook.verify(secret:, payload:, timestamp:, signature:)` | `true` or `false` | Five minutes of tolerance. | --- # Rust > Read identification events with a secret key and verify signed webhooks from Rust, with an async client built on Tokio and typed events. The `fingerly` crate reads stored events by request ID and verifies webhook signatures. The client is async, built on Tokio and `reqwest`, cheap to clone, and safe to share across tasks. ## Requirements - Rust 1.75 or newer, with the Tokio runtime. - A [secret key](https://docs.fingerly.io/docs/api-keys), and a webhook signing secret if you receive [webhooks](https://docs.fingerly.io/docs/webhooks). ## Install ```bash Cargo cargo add fingerly ``` ```toml Cargo.toml [dependencies] fingerly = "0.1" ``` ## Read an event Create one client with your secret key and reuse it. The key decides the regional API and the environment the client reads. ```rust Rust let fingerly = fingerly::Client::new(std::env::var("FINGERLY_SECRET_KEY")?); let event = fingerly.events().get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4").await?; ``` An event has the fields listed in [Get an event](https://docs.fingerly.io/reference/get-event#response). `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](https://docs.fingerly.io/docs/server-side-verification). ```rust Rust async fn decide(fingerly: &fingerly::Client, order_id: &str, request_id: &str) -> Result { let event = match fingerly.events().get(request_id).await { Ok(event) => event, Err(fingerly::Error::Api { status: 404, .. }) => return Ok(Decision::Refuse), Err(error) => return Err(error), }; if event.tag.as_deref() != Some(&format!("checkout:{order_id}")) { return Ok(Decision::Refuse); } if chrono::Utc::now() - event.occurred_at > chrono::Duration::minutes(2) { return Ok(Decision::Refuse); } Ok(match event.suspect_level { Some(Level::High) => Decision::Review, Some(Level::Medium) => Decision::Challenge, _ => Decision::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. ```rust Rust async fn fingerly_webhook(State(state): State, headers: HeaderMap, body: Bytes) -> StatusCode { let header = |name| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default(); if !fingerly::webhook::verify( &state.webhook_secret, &body, header("x-fingerly-timestamp"), header("x-fingerly-signature"), ) { return StatusCode::BAD_REQUEST; } let event: fingerly::WebhookEvent = serde_json::from_slice(&body).unwrap(); state.queue.enqueue(event.id.clone(), body).await; // deduplicate on the event ID StatusCode::NO_CONTENT } ``` ## API | Member | Returns | Notes | | --- | --- | --- | | `fingerly::Client::new(secret_key)` | `Client` | `Client::builder()` sets an endpoint or a `reqwest::Client`. | | `client.events().get(request_id).await` | `Result` | A non-2xx response is `Error::Api { status, .. }`. | | `client.events().list(&ListEvents).await` | `Result` | `ListEvents` has `from`, `to`, `page`, `limit`, `visitor`, `level`. | | `fingerly::webhook::verify(secret, body, timestamp, signature)` | `bool` | Five minutes of tolerance. | Events deserialize with `serde`. Optional fields are `Option`s, and `suspect_level` is a `Level` enum. --- # Cloudflare Worker proxy > Deploy a first-party proxy for the browser SDK in your own Cloudflare account. It forwards only identify requests, strips cookies and caps request bodies. The Worker proxy runs in your Cloudflare account on a path of your own site. It forwards only the browser SDK's identify and deferred-report requests, to the region your proxy key belongs to, and cannot read events. ## Before you start - A Cloudflare account with your site's zone. - A [proxy key](https://docs.fingerly.io/docs/proxy-integrations#proxy-keys), `fly_px_…`, for the same environment as your public key. - Node.js and `pnpm`, to deploy with Wrangler. ## Deploy ### Step 1: Configure the Worker Copy `wrangler.toml.example` to `wrangler.toml`. Choose a neutral Worker name and route. Avoid words such as `fingerprint`, `tracking` or `fingerly` in the public path, which content blockers look for. ```toml wrangler.toml name = "edge-metrics" main = "src/index.ts" compatibility_date = "2026-09-01" routes = [{ pattern = "shop.example.com/metrics/*", zone_name = "example.com" }] [vars] FINGERLY_ROUTE_PREFIX = "/metrics" FINGERLY_ALLOWED_ORIGINS = "https://shop.example.com,https://example.com" ``` ### Step 2: Add the proxy key ```bash Terminal pnpm wrangler secret put FINGERLY_PROXY_KEY ``` Paste the proxy key when prompted. It is stored as an encrypted Worker secret. ### Step 3: Deploy ```bash Terminal pnpm install pnpm run deploy ``` ### Step 4: Point the browser SDK at it ```ts fingerly.ts const fingerly = await load({ apiKey: 'fly_pk_us_production_…', endpoints: '/metrics', fallbackToDefaultEndpoint: true, // optional }) ``` ## Configuration | Name | Kind | Meaning | | --- | --- | --- | | `FINGERLY_PROXY_KEY` | Secret | Your proxy key. Its prefix decides which regional API the Worker forwards to. | | `FINGERLY_ROUTE_PREFIX` | Variable | The path before `/api/v1`. Must start with `/`. | | `FINGERLY_ALLOWED_ORIGINS` | Variable | Comma-separated exact origins allowed to call the Worker. A request from any other origin gets `403`. | ## What the Worker does - Answers CORS preflights for allowed origins, and only `POST` otherwise. - Forwards only `{prefix}/api/v1/identify` and `{prefix}/api/v1/events/{request_id}/supplement`; anything else gets `404`. - Refuses bodies over 1 MiB with `413` and gives Fingerly 5 seconds before answering `502`. - Sends the visitor's address from `CF-Connecting-IP`, the allowed origin and the user agent, authenticated with the proxy key. - Strips `Set-Cookie` from responses, never follows redirects, and never logs bodies or credentials. > **Note:** Forwarding only ever goes to `us.api.fingerly.io` or `eu.api.fingerly.io`, chosen by the proxy key. Your data stays in its region. Where Cloudflare processes the request at the edge is governed by your own Cloudflare account settings. --- # API overview > The Fingerly HTTP API: regional base URLs, the two kinds of key, JSON conventions, and the endpoints your SDKs and your server call. The Fingerly API is a small JSON-over-HTTPS API with three audiences. Client SDKs call the **client API** with a public key to identify visitors. Your backend calls the **server API** with a secret key to read what was identified. Your automation calls the **management API** with a management key to manage keys, webhook endpoints and risk weights. Most integrations never call the client API directly: the SDKs do. ## Base URL Each region has its own API, and every key belongs to one region. Use the base URL of your key's region. | Region | Base URL | Keys | Status | | --- | --- | --- | --- | | United States | `https://us.api.fingerly.io/api/v1` | `fly_pk_us_…`, `fly_sk_us_…`, `fly_px_us_…` | Available | | European Union | `https://eu.api.fingerly.io/api/v1` | `fly_pk_eu_…`, `fly_sk_eu_…`, `fly_px_eu_…` | Coming soon | Management keys, `fly_mk_us_…` and `fly_mk_eu_…`, belong to a region the same way. The SDKs read the region from the key and choose the base URL for you. A key sent to another region's API is refused. See [regions and data residency](https://docs.fingerly.io/docs/regions). ## Endpoints | Endpoint | Key | Purpose | | --- | --- | --- | | [`GET /events`](https://docs.fingerly.io/reference/list-events) | Secret | List events in a time window. | | [`GET /events/{request_id}`](https://docs.fingerly.io/reference/get-event) | Secret | Read one event with its archived detail. | | [`POST /identify`](https://docs.fingerly.io/reference/identify) | Public | Submit a signal report and get the verdict. | | [`POST /events/{request_id}/supplement`](https://docs.fingerly.io/reference/deferred-report) | Public | Attach the deferred report to an identification. | | [`POST /attestation/challenge`](https://docs.fingerly.io/reference/attestation-challenge) | Public | Issue a one-time attestation challenge for the Android SDK. | | [`/management/…`](https://docs.fingerly.io/reference/management/overview) | Management | Manage SDK keys, proxy keys, webhook endpoints and risk weights. | ## Conventions - Request and response bodies are JSON (`application/json`). Unknown request fields are rejected. - Request bodies are limited to 1 MiB. Client API requests may be sent with `Content-Encoding: gzip`, and the limit applies after decompression. - Timestamps are RFC 3339 in UTC, such as `2026-09-16T09:41:12.482Z`. - Request IDs are UUIDv7, so they sort by time and carry their own timestamp. - Optional response fields are omitted when they do not apply, unless a page says a field is `null`. - Every response carries an `X-Request-Id` header. Include it when you contact support. ## Versioning The version is part of the path, `/api/v1`. Within a version, Fingerly adds fields and endpoints but does not remove or rename them, and never changes what a field means. Write clients that ignore fields they do not know. Signal names are permanent too. If a detection ever changes what it measures, it gets a new name. ## Health `GET /api/v1/healthz` answers when the API process is up; `GET /api/v1/readyz` answers when it can serve traffic. Neither needs a key. ## OpenAPI The whole public API is described in an OpenAPI 3.1 document, with a Postman collection generated from it. See [OpenAPI and Postman](https://docs.fingerly.io/reference/openapi). - [Authentication](https://docs.fingerly.io/reference/authentication): Public, secret, proxy and management keys. - [Errors](https://docs.fingerly.io/reference/errors): Statuses, codes and what to do. --- # Authentication > Every API request is authenticated with a key in the x-api-key header. Which key you send decides what the request may do and where it may come from. Send your key in the `x-api-key` header. Keys are never accepted in the query string. ```bash Request curl "https://us.api.fingerly.io/api/v1/events" -H "x-api-key: $FINGERLY_SECRET_KEY" ``` ## Kinds of key | Kind | Prefix | Used by | May call | | --- | --- | --- | --- | | Public | `fly_pk_{region}_{environment}_` | Client SDKs, in browsers and apps | The client API | | Secret | `fly_sk_{region}_{environment}_` | Your backend | The server API, and the client API from a server | | Proxy | `fly_px_{region}_{environment}_` | Your first-party proxy | Forwards client API requests with visitor details | | Management | `fly_mk_{region}_` | Your automation and infrastructure-as-code tools | The [management API](https://docs.fingerly.io/reference/management/overview) | The prefix spells out the key's region and environment, so `fly_pk_us_development_…` is a public key for the US region's development environment. Everything after the prefix is random. See [API keys and environments](https://docs.fingerly.io/docs/api-keys). ## Public keys A public key is safe to ship in a web page or an app. What makes it safe is where it is accepted from: - **In a browser**, the request's `Origin` must exactly match one of the key's allowed origins, scheme and port included. A key with no allowed origins refuses every browser request. - **In a native app**, the mobile SDKs identify the platform instead of sending an origin. - A public key can only submit identifications. It can never read events. ## Secret keys A secret key reads events from its own organization and environment. It is refused when the request carries an `Origin` header, so a secret key pasted into front-end code fails immediately rather than leaking quietly. The one exception is a **development** secret key used from the Try it panel on these docs, which reads only development events. > **Warning:** Keep secret keys on your servers. Anyone holding one can read your visitors' events for its environment. If one leaks, revoke it in the dashboard and issue a new one: revocation takes effect immediately. ## Proxy keys A proxy lets the browser SDK reach Fingerly through your own domain. Because the request then arrives from your server, the proxy must say who the visitor really is, and a proxy key is what makes those details trusted. | Header | Value | | --- | --- | | `x-api-key` | The browser's public key, passed through. | | `x-fingerly-proxy-key` | Your proxy key. | | `x-fingerly-client-ip` | The visitor's IP address, from infrastructure you trust. | | `x-fingerly-origin` | The page's origin, checked against the public key's allowed origins. | | `x-fingerly-user-agent` | The visitor's user agent, up to 4,096 bytes. | ## Management keys A management key calls the [management API](https://docs.fingerly.io/reference/management/overview) for its organization, with the permissions of the role it was issued with, `admin` or `developer`. It belongs to no environment, is refused with an `Origin` header, and cannot issue or revoke management keys. Owners and admins issue them in **Integration > Management keys**. ## Proxy key rules The proxy key and the public key must belong to the same organization, environment and region. Forwarded visitor headers are ignored unless the request is authenticated with a proxy key or a secret key. A proxy key cannot read events. > **Tip:** You rarely set these headers yourself. The [Node.js SDK](https://docs.fingerly.io/docs/sdks/node#serve-the-browser-sdk-from-your-domain) and the [Cloudflare Worker](https://docs.fingerly.io/docs/sdks/cloudflare-worker) do it for you. ## When authentication fails | Status | Code | Meaning | | --- | --- | --- | | `401` | `unauthorized` | The key is unknown, revoked, expired, from another region, the wrong kind for the endpoint, or not allowed from where the request came. The response is the same for every cause. | | `402` | `billing_blocked` | The key is valid, but its organization is not accepting traffic. Reading events is not affected. | An identify request refused for an expired or revoked key, a disallowed origin or a blocked organization also sends an [`identification.refused`](https://docs.fingerly.io/reference/webhooks/identification-refused) webhook, which says which of those it was. --- # Errors > How the Fingerly API reports errors: one JSON error shape, conventional HTTP statuses, and stable error codes you can branch on. Errors use conventional HTTP statuses and one JSON body shape. Branch on `error.code`, which is stable; `error.message` is written for people and may change. ```json 422 Unprocessable Entity { "error": { "code": "validation_error", "message": "The request could not be validated.", "status": 422, "details": [ { "field": "tag", "location": "body", "issue": "must be a string" } ] }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` **Error body** - `error.code` (string): A stable, machine-readable code. - `error.message` (string): A human-readable explanation. - `error.status` (integer): The HTTP status, repeated. - `error.details` (array): For validation errors: which `field`, where (`body`, `query`, `path`, `header`) and the `issue`. Omitted otherwise. - `request_id` (string): The request's ID, also in the `X-Request-Id` header. ## Error codes | Status | Code | What happened | What to do | | --- | --- | --- | --- | | `401` | `unauthorized` | The key did not authenticate for this request. | Check the key, its kind, its region and its allowed origins. | | `402` | `billing_blocked` | The organization is not accepting traffic. | Resolve billing in the dashboard. | | `402` | `no_credit` | The organization's balance is used up. | Add funds or turn on auto top-up. | | `404` | `event_not_found` | No event with this request ID in the key's environment. | Check the ID and the environment. Events are readable for 30 days. | | `409` | `supplement_conflict` | A different deferred report was already accepted for this request. | Do not retry with different content. | | `413` | `payload_too_large` | The body is over 1 MiB. | Send a smaller body. | | `415` | `unsupported_media_type` | The body is not JSON. | Send `Content-Type: application/json`. | | `422` | `validation_error` | The body or parameters are malformed. | Read `error.details`. | | `422` | `idempotency_key_too_long` | The `Idempotency-Key` is over 255 characters. | Use a shorter key, such as a UUID. | | `422` | `invalid_request_id` | The request ID is not a Fingerly request ID. | Pass the `request_id` exactly as returned. | | `422` | `invalid_window` | The time window, page, level or visitor filter is not valid. | See [List events](https://docs.fingerly.io/reference/list-events). | | `422` | `invalid_supplement` | The deferred report or its token is not valid, or the token expired. | Deferred reports must arrive within 24 hours. | | `422` | `invalid_visitor_metadata` | A proxy forwarded a malformed visitor address, origin or user agent. | Fix the proxy's forwarded headers. | | `429` | `rate_limited` | The organization is over its rate limit. | Wait `Retry-After` seconds, then retry. | | `503` | `service_unavailable` | Fingerly could not answer just now. | Retry with backoff and the same `Idempotency-Key`. | ## Retrying - **Retry** `429` after `Retry-After`, and `5xx` and network errors with exponential backoff. Reuse the same `Idempotency-Key` so a retried identification is answered and charged once. - **Do not retry** `401`, `402`, `413`, `415` or `422`. They describe the request or the account, not a passing condition. > **Note:** The client SDKs already follow these rules. See [Idempotency and retries](https://docs.fingerly.io/reference/idempotency). --- # Idempotency and retries > Retry an identification safely: the Idempotency-Key header makes a repeated request return the first answer, charged once. A network can fail after Fingerly has answered but before the answer reaches the client. Retrying then would identify the visitor twice and charge twice. An idempotency key prevents both. ## Idempotency keys Send a unique `Idempotency-Key` header with `POST /identify`, and send the same value with every retry of that request. ```http Request POST /api/v1/identify HTTP/1.1 Host: us.api.fingerly.io Content-Type: application/json x-api-key: fly_pk_us_production_… Idempotency-Key: 7d5e1f2a-3c4b-4d6e-8f90-a1b2c3d4e5f6 ``` - Keys are scoped to your organization and remembered for at least one hour. - Use a random UUID. Keys may be up to 255 characters. - A repeat of a key that was already answered returns `200` with the first `request_id` and `"duplicate": true`, is not charged, and does not count against your rate limit. - A request refused with `402` or `429` releases its key, so the retry is processed normally. ```json Duplicate response { "request_id": "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4", "duplicate": true } ``` > **Tip:** A duplicate response carries only the request ID. Keep the first response, or read the event on your server with [Get an event](https://docs.fingerly.io/reference/get-event). ## How the SDKs retry | SDK | Attempts | Retries on | Backoff | | --- | --- | --- | --- | | JavaScript and web frameworks | 3 | Network errors, timeouts, `408`, `425`, `429`, `5xx` | Full jitter from 100 ms, capped at 2 s | | iOS and Android | 3 | Network errors, `429`, `5xx` | Full jitter from 100 ms, capped at 2 s | Every SDK attempt times out after 5 seconds and reuses one idempotency key per `identify()` call. ## Other endpoints - `GET` requests are naturally safe to retry. - `POST /events/{request_id}/supplement` deduplicates on content: resending the same deferred report returns `202` with `"duplicate": true`, and a different one returns `409`. --- # OpenAPI and Postman > Download the OpenAPI 3.1 description of the Fingerly API and a Postman collection generated from it, to explore the API or generate a client. The client, server and management APIs are described in one OpenAPI 3.1 document. It is generated from the routes the API serves, so it lists exactly the endpoints, parameters and fields they have. | File | Use it to | | --- | --- | | [openapi.json](https://docs.fingerly.io/openapi.json) | Generate a client, validate requests in tests, or import into an API tool. | | [fingerly.postman_collection.json](https://docs.fingerly.io/fingerly.postman_collection.json) | Explore the API in Postman, one request per operation. | ```bash Download curl -O https://docs.fingerly.io/openapi.json curl -O https://docs.fingerly.io/fingerly.postman_collection.json ``` ## What the document covers | Tag | Operations | Key | | --- | --- | --- | | Client API | [Identify](https://docs.fingerly.io/reference/identify), [deferred reports](https://docs.fingerly.io/reference/deferred-report), [attestation challenges](https://docs.fingerly.io/reference/attestation-challenge) | Public | | Server API | [List events](https://docs.fingerly.io/reference/list-events), [get an event](https://docs.fingerly.io/reference/get-event) | Secret | | Management API | [SDK keys](https://docs.fingerly.io/reference/management/sdk-keys), [proxy keys](https://docs.fingerly.io/reference/management/proxy-keys), [webhook endpoints](https://docs.fingerly.io/reference/management/webhooks), [risk weights](https://docs.fingerly.io/reference/management/risk-weights) | Management | - Servers are listed per region. Use the one your keys belong to. - Each operation names the kind of key it accepts, as the `publicKey`, `secretKey` or `managementKey` security scheme. All three are sent in the `x-api-key` header. - Every error response uses the [error body](https://docs.fingerly.io/reference/errors). - Webhook events are not operations you call, so they are not in the document. See the [event envelope](https://docs.fingerly.io/reference/webhooks/envelope). > **Note:** The signal report the client SDKs send, and the archived submission a single event carries, are described as plain objects. Their contents are produced and read by Fingerly and are not a public contract. ## Import into Postman - In Postman, choose **Import** and select `fingerly.postman_collection.json`. - Open the collection's **Variables** and set `baseUrl`, the keys you want to use, and `organizationId`. - Send **Describe the management key** first to read your `organizationId`. > **Tip:** Use development keys while you explore. They run the same detection, are never billed, and keep test traffic out of production. ## Generate a client Any OpenAPI 3.1 generator can build a client from the document. Operation IDs are named for what the operation does, such as `listEvents` and `issueSdkKey`. ```bash TypeScript types npx openapi-typescript https://docs.fingerly.io/openapi.json -o fingerly-api.d.ts ``` ```bash OpenAPI Generator npx @openapitools/openapi-generator-cli generate \ -i https://docs.fingerly.io/openapi.json \ -g go \ -o ./fingerly-client ``` To read events and verify webhooks, the [server SDKs](https://docs.fingerly.io/docs/sdks#server) are simpler than a generated client. ## Versions The document describes API `v1`. It changes only in the ways the [versioning policy](https://docs.fingerly.io/docs/versioning) allows within a version, and every change is listed in the [changelog](https://docs.fingerly.io/docs/changelog). Write clients that ignore fields they do not know. --- # List events > List identification events in a time window, newest first, filtered by visitor or suspect level. ```http GET /api/v1/events ``` Authentication: Secret key (`x-api-key: fly_sk_…`) Returns events from the secret key's own organization and environment, newest first. The window can reach back 30 days. **Query parameters** - `from` (string, default `24 hours ago`): Start of the window, inclusive, RFC 3339. - `to` (string, default `now`): End of the window, exclusive, RFC 3339. - `page` (integer, default `1`): One-based page number. The offset may not exceed 10,000 events. - `limit` (integer, default `10`): Events per page, at most 200. - `visitor` (string): Only events for this exact visitor ID. - `level` (string): Only events at `low`, `medium` or `high`. `from` must be before `to`, the window may span at most 30 days, and it may not start more than 30 days ago. Otherwise the request fails with `422 invalid_window`. ## Response - `rows` (array): The events on this page. Each has the [event fields](https://docs.fingerly.io/reference/get-event#response). - `page` (integer): This page's number. - `page_size` (integer): The page size applied. There is no total count. A page with fewer rows than `page_size` is the last one. ## Errors | Status | Code | When | | --- | --- | --- | | `401` | `unauthorized` | The key is not a valid secret key. | | `422` | `invalid_window` | The window, page offset, level or visitor filter is not valid. | | `422` | `validation_error` | A parameter has the wrong type. | > **Note:** The server API does not answer browsers: a secret key sent with an `Origin` header is refused. Call it from your backend. ## Example request ```bash cURL curl "https://us.api.fingerly.io/api/v1/events?level=high&limit=50" \ -H "x-api-key: $FINGERLY_SECRET_KEY" ``` ```ts Node.js const page = await fingerly.events.list({ level: 'high', limit: 50 }) ``` ```python Python page = fingerly.events.list(level="high", limit=50) ``` ```python Python (async) page = await fingerly.events.list(level="high", limit=50) ``` ```go Go page, err := client.Events.List(ctx, &fingerly.EventListParams{Level: "high", Limit: 50}) ``` ```java Java EventPage page = fingerly.events().list(EventListParams.builder().level("high").limit(50).build()); ``` ```csharp .NET var page = await fingerly.Events.ListAsync(new EventListOptions { Level = "high", Limit = 50 }); ``` ```php PHP $page = $fingerly->events->list(['level' => 'high', 'limit' => 50]); ``` ```ruby Ruby page = fingerly.events.list(level: "high", limit: 50) ``` ```rust Rust let page = fingerly.events().list(&ListEvents { level: Some(Level::High), limit: Some(50), ..Default::default() }).await?; ``` ## Example response ```json 200 { "rows": [ { "request_id": "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4", "visitor_id": "X9pL2mRc7KvT4bQw8NdF", "occurred_at": "2026-09-16T09:41:12.482Z", "state": "enriched", "environment": "production", "platform": "web", "sdk_platform": "web", "sdk_version": "0.1.0", "origin": "https://shop.example.com", "tag": "checkout:8412", "ip_address": "203.0.113.42", "country_code": "DE", "country_name": "Germany", "asn": "AS64502", "asn_name": "Example Hosting", "anonymity_network": "tor", "agent": "Firefox", "agent_version": "128.0", "os": "Windows", "device_kind": "desktop", "visitor_kind": "automation", "suspect_score": 37, "suspect_level": "high", "triggers": [ { "signal": "tor", "weight": 14, "confidence": "high" }, { "signal": "bot", "weight": 9, "confidence": "medium" }, { "signal": "browser_tampering", "weight": 8, "confidence": "medium" }, { "signal": "high_activity", "weight": 6, "confidence": "low" } ], "has_document": true } ], "page": 1, "page_size": 50 } ``` ```json 422 { "error": { "code": "invalid_window", "message": "the event window must be positive and within retention", "status": 422 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` --- # Get an event > Read one identification event by its request ID with a secret key. This is how your server verifies what a client reported. ```http GET /api/v1/events/{request_id} ``` Authentication: Secret key (`x-api-key: fly_sk_…`) Returns the stored event for a request ID, from the secret key's own organization and environment. Use it to [verify an identification](https://docs.fingerly.io/docs/server-side-verification) before you act on it. **Path parameters** - `request_id` (string, required): The `request_id` an SDK returned. ## Response - `request_id` (string): The identification's ID, a UUIDv7. - `visitor_id` (string): The visitor the device was identified as. - `occurred_at` (string): When the request arrived, RFC 3339 in UTC. - `state` (string): `enriched`, or `unavailable` when the network lookup could not run. - `environment` (string): `production`, `staging` or `development`. - `platform` (string): The scoring profile applied: `web`, `android` or `ios`. - `sdk_platform` (string): The SDK that sent the report, such as `web`, `ios` or `flutter-android`. - `sdk_version` (string): The SDK's version. - `origin` (string): The site the request came from. Omitted for native apps. - `tag` (string): Your tag, as sent. Omitted when none was sent. - `ip_address` (string): The visitor's IP address. - `country_code` (string): ISO 3166-1 alpha-2 country of the address. - `country_name` (string): The country's name. - `asn` (string): The network's autonomous system number. - `asn_name` (string): The network's name. - `anonymity_network` (string): `tor`, `vpn`, `proxy` or `hosting`. Omitted for an ordinary connection. - `agent` (string): The browser or app. - `agent_version` (string): Its version. - `os` (string): The operating system. - `device_kind` (string): `desktop`, `mobile`, `tablet` or `server`. - `visitor_kind` (string): `human`, `ai_bot`, `automation` or `search_crawler`. - `suspect_score` (integer | null): The score. `null` when the request was not scored. - `suspect_level` (string): `low`, `medium` or `high`. Omitted when not scored. - `triggers` (array): The signal groups that fired, heaviest first. Always present, possibly empty. - `signal` (string): The [signal group](https://docs.fingerly.io/docs/signals), such as `vpn` or `bot`. - `weight` (integer): The group's combined weight in this score. - `confidence` (string): `low`, `medium` or `high`. - `has_document` (boolean): Whether the archived submission can be read with [Get an event](https://docs.fingerly.io/reference/get-event). **Archived detail** - `document` (object): The archived submission as Fingerly stored it, including the scoring details: the threshold and weights profile applied. Present when `has_document` is `true` and the archive could be read. - `deferred_signals` (object): The deferred report attached to this request, when one arrived. > **Note:** Events are readable for 30 days. An event normally becomes readable within a few seconds of the identification. Allow for that when a client sends you a request ID immediately. ## Errors | Status | Code | When | | --- | --- | --- | | `401` | `unauthorized` | The key is not a valid secret key. | | `404` | `event_not_found` | No such event in this environment, or it is older than 30 days. | | `422` | `invalid_request_id` | The path is not a Fingerly request ID. | ## Example request ```bash cURL curl "https://us.api.fingerly.io/api/v1/events/01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4" \ -H "x-api-key: $FINGERLY_SECRET_KEY" ``` ```ts Node.js const event = await fingerly.events.get('01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4') ``` ```python Python event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```python Python (async) event = await fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```go Go event, err := client.Events.Get(ctx, "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```java Java Event event = fingerly.events().get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4"); ``` ```csharp .NET var ev = await fingerly.Events.GetAsync("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4"); ``` ```php PHP $event = $fingerly->events->get('01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4'); ``` ```ruby Ruby event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4") ``` ```rust Rust let event = fingerly.events().get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4").await?; ``` ## Example response ```json 200 { "request_id": "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4", "visitor_id": "X9pL2mRc7KvT4bQw8NdF", "occurred_at": "2026-09-16T09:41:12.482Z", "state": "enriched", "environment": "production", "platform": "web", "sdk_platform": "web", "sdk_version": "0.1.0", "origin": "https://shop.example.com", "tag": "checkout:8412", "ip_address": "203.0.113.42", "country_code": "DE", "country_name": "Germany", "asn": "AS64502", "asn_name": "Example Hosting", "anonymity_network": "tor", "agent": "Firefox", "agent_version": "128.0", "os": "Windows", "device_kind": "desktop", "visitor_kind": "automation", "suspect_score": 37, "suspect_level": "high", "triggers": [ { "signal": "tor", "weight": 14, "confidence": "high" }, { "signal": "bot", "weight": 9, "confidence": "medium" }, { "signal": "browser_tampering", "weight": 8, "confidence": "medium" }, { "signal": "high_activity", "weight": 6, "confidence": "low" } ], "has_document": true } ``` ```json 404 { "error": { "code": "event_not_found", "message": "no such event", "status": 404 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` --- # Identify a visitor > Submit an SDK signal report and receive the visitor ID, the suspect score and the signals behind it in the same response. ```http POST /api/v1/identify ``` Authentication: Public key (`x-api-key: fly_pk_…`) Submits a signal report collected by a client SDK. The response carries the complete verdict. There is no second call to make. > **Note:** The client SDKs build and send this request for you. Call it directly only if you are writing your own transport, for example a proxy. **Headers** - `x-api-key` (string, required): A public key, or a secret key from a server. - `Idempotency-Key` (string): Up to 255 characters. Makes retries safe. See [Idempotency](https://docs.fingerly.io/reference/idempotency). - `Content-Encoding` (string): `gzip` to send a compressed body. **Body** - `sdk` (object, required): The SDK that collected the report. - `platform` (string): `web`, `ios`, `android`, or a bridge such as `react-native-ios`. - `version` (string): The SDK version. - `signals` (object, required): The signal report the SDK collected. Its contents are produced by the SDKs and are not a public contract. - `tag` (string): Your own reference for this identification, echoed on the event and in webhooks. ## Response - `request_id` (string): Identifies this identification. A UUIDv7. - `deferred_token` (string): A capability for sending this request's [deferred report](https://docs.fingerly.io/reference/deferred-report) within 24 hours. Omitted on duplicates. - `duplicate` (boolean): `true` when this `Idempotency-Key` was already answered. Only `request_id` is meaningful then. - `visitor_id` (string): The stable visitor identifier: 20 letters and digits. Always present on a new identification. - `visitor_is_new` (boolean): Whether your organization is seeing this visitor for the first time. - `identifiable` (boolean): `false` when the report carried too little to identify anyone. The request is still scored. - `visitor_confidence` (integer): From 0 to 100. `100`: seen before exactly. `85` to `99`: recognised after the device changed. `0`: a new visitor ID. - `state` (string): `enriched`, or `unavailable` when the network lookup could not run. - `reason` (string): A short token explaining an `unavailable` state. Omitted otherwise. - `suspect_score` (integer): The weighted sum of the signals that fired. Omitted when the request was not scored, which is different from `0`. - `suspect_level` (string): `low`, `medium` or `high`, from your [threshold](https://docs.fingerly.io/docs/suspect-score#levels). - `triggers` (array): Each signal that fired, heaviest first. Omitted when none did. - `signal` (string): The signal, such as `tor` or `automation`. - `group` (string): The signal's group, such as `bot` for `automation`. - `weight` (integer): The weight the signal added, from the weights in force. - `confidence` (string): `low`, `medium` or `high`. **Response headers** - `Fingerly-Balance-Micros` (integer): Your remaining balance in millionths of a US dollar. Reflects production traffic. - `RateLimit-Limit` (integer): Your organization's rate limit, per second. - `RateLimit-Remaining` (integer): What remained of the limit when the request was admitted. - `Retry-After` (integer): On `429`, seconds to wait before retrying. These headers can be read from browser JavaScript as well as from servers and proxies. ## Billing A production identification costs $0.003, or $0.0005 when `identifiable` is `false`. Development and staging keys, duplicates, refused requests and requests whose `state` is `unavailable` are free. See [billing](https://docs.fingerly.io/docs/billing). ## Errors | Status | Code | When | | --- | --- | --- | | `401` | `unauthorized` | The key did not authenticate from this origin or platform. | | `402` | `billing_blocked` | The organization is not accepting traffic. | | `402` | `no_credit` | The balance is used up. | | `413` | `payload_too_large` | The body is over 1 MiB. | | `422` | `idempotency_key_too_long` | The key is over 255 characters. | | `422` | `invalid_visitor_metadata` | A proxy forwarded malformed visitor details. | | `429` | `rate_limited` | Over the organization's rate limit. | | `503` | `service_unavailable` | Retry with the same `Idempotency-Key`. | ## Example request ```bash cURL curl -X POST "https://us.api.fingerly.io/api/v1/identify" \ -H "Content-Type: application/json" \ -H "x-api-key: fly_pk_us_production_…" \ -H "Idempotency-Key: 7d5e1f2a-3c4b-4d6e-8f90-a1b2c3d4e5f6" \ -H "Origin: https://shop.example.com" \ -d '{ "sdk": { "platform": "web", "version": "0.1.0" }, "signals": { "schema": 1, "…": "…" }, "tag": "checkout:8412" }' ``` ```ts JavaScript const result = await fingerly.identify({ tag: 'checkout:8412' }) ``` ```swift Swift let result = try await fingerly.identify(tag: "checkout:8412") ``` ```kotlin Kotlin val result = fingerly.identify(tag = "checkout:8412") ``` ## Example response ```json 200 { "request_id": "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4", "deferred_token": "v1.eyJyZXF1ZXN0X2lkIjoi….…", "visitor_id": "X9pL2mRc7KvT4bQw8NdF", "visitor_is_new": false, "identifiable": true, "visitor_confidence": 100, "state": "enriched", "suspect_score": 37, "suspect_level": "high", "triggers": [ { "signal": "tor", "group": "tor", "weight": 14, "confidence": "high" }, { "signal": "automation", "group": "bot", "weight": 9, "confidence": "medium" }, { "signal": "tampering", "group": "browser_tampering", "weight": 8, "confidence": "medium" }, { "signal": "high_activity", "group": "high_activity", "weight": 6, "confidence": "low" } ] } ``` ```json 200 duplicate { "request_id": "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4", "duplicate": true } ``` ```json 429 { "error": { "code": "rate_limited", "message": "this organisation is sending faster than its rate limit allows", "status": 429 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` --- # Submit a deferred report > Attach the signals an SDK collects after its initial budget to an identification that has already been answered. ```http POST /api/v1/events/{request_id}/supplement ``` Authentication: Public key (`x-api-key: fly_pk_…`) Some signals take longer to read than the initial collection budget allows. The browser SDK sends them afterwards with this endpoint. A deferred report is archived with the event and returned as `deferred_signals` by [Get an event](https://docs.fingerly.io/reference/get-event). It never changes the visitor ID, the score, billing or webhooks. > **Note:** The JavaScript SDK sends deferred reports automatically. You do not need to call this endpoint. **Path parameters** - `request_id` (string, required): The `request_id` from the identify response. **Body** - `token` (string, required): The `deferred_token` from the identify response. Valid for 24 hours, for the same key and origin. - `sdk` (object, required): The SDK's `platform` and `version`. - `signals` (object, required): The deferred-tier report. Produced by the SDK. ## Response `202 Accepted`: - `request_id` (string): The request the report was attached to. - `duplicate` (boolean): `true` when this exact report was already accepted. Omitted otherwise. Deferred reports are free. ## Errors | Status | Code | When | | --- | --- | --- | | `409` | `supplement_conflict` | A different report was already accepted for this request. | | `422` | `invalid_supplement` | The token is invalid, expired or for another request, or the report is not a deferred report. | | `422` | `invalid_request_id` | The path is not a Fingerly request ID. | | `503` | `service_unavailable` | The report could not be accepted just now. | ## Example request ```bash cURL curl -X POST "https://us.api.fingerly.io/api/v1/events/01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4/supplement" \ -H "Content-Type: application/json" \ -H "x-api-key: fly_pk_us_production_…" \ -H "Origin: https://shop.example.com" \ -d '{ "token": "v1.eyJyZXF1ZXN0X2lkIjoi….…", "sdk": { "platform": "web", "version": "0.1.0" }, "signals": { "tiers": ["deferred"], "…": "…" } }' ``` ## Example response ```json 202 { "request_id": "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4" } ``` ```json 409 { "error": { "code": "supplement_conflict", "message": "a different deferred report was already accepted", "status": 409 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` --- # Request an attestation challenge > Issue a one-time, five-minute challenge that the Android SDK has the device keystore attest to, proving the report is fresh. ```http POST /api/v1/attestation/challenge ``` Authentication: Public key (`x-api-key: fly_pk_…`) Issues a random challenge bound to your organization and SDK key. The [Android SDK](https://docs.fingerly.io/docs/sdks/android#hardware-backed-attestation) requests one before collecting, has the device's hardware keystore attest to it, and includes the attestation in its identify request. The server checks it and consumes the challenge, so an attestation cannot be replayed. > **Note:** The Android SDK calls this endpoint automatically. See [mobile app attestation](https://docs.fingerly.io/docs/mobile-attestation). The request has no body. ## Response - `challenge` (string): 32 random bytes, base64-encoded. - `expires_at` (string): When the challenge stops being accepted: five minutes after it was issued. Each challenge can be used once. Challenges are free. ## Errors | Status | Code | When | | --- | --- | --- | | `401` | `unauthorized` | The key did not authenticate. | | `503` | `service_unavailable` | A challenge could not be issued just now. | ## Example request ```bash cURL curl -X POST "https://us.api.fingerly.io/api/v1/attestation/challenge" \ -H "x-api-key: fly_pk_us_production_…" \ -H "x-fingerly-sdk-platform: android" ``` ## Example response ```json 200 { "challenge": "q8Xv0bJ3c2Vj3kQm1s9Qe2Ww5rT7yU8iO1pA3sD5fG0=", "expires_at": "2026-09-16T09:46:12Z" } ``` --- # Management API > Manage SDK keys, proxy keys, webhook endpoints and risk weights from code, with a management key, for infrastructure as code and automation. The management API does from code what **Integration** and **Smart Signals > Suspect Score** do in the dashboard: issue and revoke SDK keys and proxy keys, register webhook endpoints, and set risk weights. Use it from infrastructure-as-code tools, deployment pipelines and scripts, so a staging environment and its keys, endpoints and policy can be created the same way every time. | Resource | Operations | | --- | --- | | [SDK keys](https://docs.fingerly.io/reference/management/sdk-keys) | List, issue, revoke, replace allowed origins | | [Proxy keys](https://docs.fingerly.io/reference/management/proxy-keys) | List, issue, revoke | | [Webhook endpoints](https://docs.fingerly.io/reference/management/webhooks) | List, create, update, pause, delete, rotate the secret, send a test event, redeliver, list deliveries | | [Risk weights](https://docs.fingerly.io/reference/management/risk-weights) | Read, save and restore the organization's and each SDK key's weights, list signals | ## Base URL The management API is part of your region's API, under `/api/v1/management`. ```text Base URL https://us.api.fingerly.io/api/v1/management ``` A management key is refused by any other region's API. ## Management keys Every request is authenticated with a **management key** in the `x-api-key` header. Management keys look like `fly_mk_us_…`: they belong to your organization and region, and to no environment, because they manage keys in all three. - Owners and admins issue them in **Integration > Management keys**. The key is shown once. - A management key cannot issue or revoke management keys, so a leaked one cannot replace itself. - Management keys are for servers. A request carrying a browser `Origin` header is refused, however valid the key. - Only a keyed hash of each key is stored, as for every other key. See [security](https://docs.fingerly.io/docs/security). > **Warning:** A management key can issue secret keys, which read your visitors' events. Keep it in a secret manager, give it the least powerful role that works, and revoke it the moment it leaks. ## Roles Each management key acts with a role, chosen when it is issued, and may do exactly what a member with that role may do. | Capability | Admin key | Developer key | | --- | --- | --- | | List, issue and revoke SDK keys, and change allowed origins | Yes | Yes | | Manage webhook endpoints | Yes | Yes | | Read risk weights and the signal list | Yes | Yes | | Change risk weights and thresholds | Yes | No | | List, issue and revoke proxy keys | Yes | No | A request the role does not allow answers `403` with the code `insufficient_role`. ## Describe the key ```http GET /api/v1/management/key ``` Authentication: Management key (`x-api-key: fly_mk_…`) Every other route names your organization in its path. Read its ID here, so a tool configured with nothing but a key can find it. **Response** - `key_id` (string): The management key's ID. - `organization_id` (string): The organization every management route acts on. - `name` (string): The key's name. - `role` (string): `admin` or `developer`. - `region` (string): The key's data region, such as `us`. ## Conventions - Request and response bodies are JSON, and errors use the [usual error body](https://docs.fingerly.io/reference/errors). - Secrets, of SDK keys, proxy keys and webhook endpoints, are returned once, in the response that creates them. Every later read shows only their last four characters. - Revoking is permanent and nothing is deleted, except webhook endpoints, which are removed. - Changes take effect on the next request: a revoked key is refused and saved weights score the next identification. - Changes made with a management key are attributed to no member: `created_by` and `revoked_by` are omitted. ## Errors | Status | Code | When | | --- | --- | --- | | `401` | `unauthorized` | The key is missing, unknown, revoked, expired, from another region, or sent from a browser. | | `403` | `insufficient_role` | The key's role does not allow the operation. | | `404` | `not_found` | No such resource in the key's organization, including a path naming another organization. | | `409` | Varies | The resource is in a state that forbids the change, such as an SDK key that is already revoked. | | `422` | Varies | The body is not valid. `error.message` says what to fix. | > **Tip:** The whole API is described in the [OpenAPI document](https://docs.fingerly.io/reference/openapi), which client generators and Postman can import. - [SDK keys](https://docs.fingerly.io/reference/management/sdk-keys): Issue and revoke keys from code. - [Risk weights](https://docs.fingerly.io/reference/management/risk-weights): Keep your scoring policy in version control. ## Example request ```bash cURL curl "https://us.api.fingerly.io/api/v1/management/key" \ -H "x-api-key: $FINGERLY_MANAGEMENT_KEY" ``` ```ts Node.js const response = await fetch('https://us.api.fingerly.io/api/v1/management/key', { headers: { 'x-api-key': process.env.FINGERLY_MANAGEMENT_KEY! }, }) const { organization_id } = await response.json() ``` ## Example response ```json 200 { "key_id": "01a0a862-5e3f-7b01-c2d4-6f7a8b9c0d1e", "organization_id": "01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37", "name": "Terraform", "role": "admin", "region": "us" } ``` ```json 401 { "error": { "code": "unauthorized", "message": "the x-api-key header is not a valid management key", "status": 401 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` --- # SDK keys > List, issue and revoke public and secret SDK keys, and replace a public key's allowed origins, with a management key. Manage the public and secret keys your SDKs and servers use. Admin and developer management keys can do all of this. See [API keys and environments](https://docs.fingerly.io/docs/api-keys) for what each kind of key is for. **Path parameter** - `organization_id` (string, required): Your organization, from [Describe the key](https://docs.fingerly.io/reference/management/overview#describe-the-key). A path naming any other organization answers `404`. ## List SDK keys ```http GET /api/v1/management/organizations/{organization_id}/sdk-keys ``` Authentication: Management key (`x-api-key: fly_mk_…`) Every key in every environment, oldest first, revoked keys included. **Response** - `keys` (SDKKey[]): The keys. - `id` (string): The key's ID. - `name` (string): The key's name. - `kind` (string): `public` or `secret`. - `environment` (string): `production`, `staging` or `development`. - `region` (string): The data region whose API accepts the key. - `prefix` (string): The start of the secret, such as `fly_pk_us_production`. - `last4` (string): The last four characters of the secret. - `allowed_origins` (string[]): The origins a public key is accepted from in browsers. Always empty for a secret key. - `status` (string): `active` or `revoked`. - `last_used_at` (string): When the key last authenticated a request. Omitted if never. - `expires_at` (string): When the key stops working. Omitted if it does not expire. - `created_at` (string): When the key was issued. - `created_by` (string): The member who issued it. Omitted for a key issued with a management key. - `revoked_at` (string): When the key was revoked. - `revoked_by` (string): The member who revoked it. Omitted when a management key did. ## Issue an SDK key ```http POST /api/v1/management/organizations/{organization_id}/sdk-keys ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Body** - `name` (string, required): What the key is called. - `kind` (string, required): `public` or `secret`. - `environment` (string, required): `production`, `staging` or `development`. - `allowed_origins` (string[]): Required, with at least one origin, for a public key. Refused on a secret key. - `expires_at` (string): When the key stops working, RFC 3339. Omit for a key that lasts until revoked. Answers `201` with `key`, an SDK key as above, and `secret`, the whole key. The secret is never returned again. ## Revoke an SDK key ```http POST /api/v1/management/organizations/{organization_id}/sdk-keys/{sdk_key_id}/revoke ``` Authentication: Management key (`x-api-key: fly_mk_…`) Refuses the key from the next request on, and answers with the revoked key. There is no undo. Revoking a key that is already revoked answers `409` with `key_revoked`. ## Replace allowed origins ```http PUT /api/v1/management/organizations/{organization_id}/sdk-keys/{sdk_key_id}/origins ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Body** - `allowed_origins` (string[], required): The complete new list. Each origin is a scheme, a host and an optional port, with no path. An empty list refuses every browser request. Replaces the list rather than adding to it, and answers with the key. Refused with `422` on a secret key. > **Note:** To give an SDK key its own risk weights, see [risk weights](https://docs.fingerly.io/reference/management/risk-weights#save-an-sdk-key-s-weights). ## Example request ```bash cURL curl -X POST "https://us.api.fingerly.io/api/v1/management/organizations/01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37/sdk-keys" \ -H "x-api-key: $FINGERLY_MANAGEMENT_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Checkout", "kind": "public", "environment": "production", "allowed_origins": [ "https://shop.example.com" ] }' ``` ```ts Node.js script const api = (path: string, init: RequestInit = {}) => fetch('https://us.api.fingerly.io/api/v1/management' + path, { ...init, headers: { 'x-api-key': process.env.FINGERLY_MANAGEMENT_KEY!, 'Content-Type': 'application/json' }, }) const { organization_id } = await (await api('/key')).json() const { keys } = await (await api('/organizations/' + organization_id + '/sdk-keys')).json() const active = (key: { name: string; status: string }) => key.name === 'Checkout' && key.status === 'active' if (!keys.some(active)) { const created = await api('/organizations/' + organization_id + '/sdk-keys', { method: 'POST', body: JSON.stringify({ name: 'Checkout', kind: 'public', environment: 'production', allowed_origins: ['https://shop.example.com'] }), }) const { secret } = await created.json() await secrets.put('FINGERLY_PUBLIC_KEY', secret) } ``` ## Example response ```json 201 { "key": { "id": "01a0a7f3-9e05-7a61-b4c7-2d8e0f3a6b19", "name": "Checkout", "kind": "public", "environment": "production", "region": "us", "prefix": "fly_pk_us_production", "last4": "q7Rk", "allowed_origins": [ "https://shop.example.com" ], "status": "active", "created_at": "2026-09-16T09:12:40Z" }, "secret": "fly_pk_us_production_…" } ``` ```json 422 { "error": { "code": "key_rejected", "message": "services: the SDK key cannot be issued as described: a public key has to name the origins it may be used from", "status": 422 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` --- # Proxy keys > List, issue and revoke the proxy keys your first-party proxies authenticate with. Admin management keys only. Proxy keys let a proxy on your own domain forward identify requests with the visitor's real details. See [proxy integrations](https://docs.fingerly.io/docs/proxy-integrations). Only **admin** management keys can manage them; a developer key answers `403`. **Path parameter** - `organization_id` (string, required): Your organization, from [Describe the key](https://docs.fingerly.io/reference/management/overview#describe-the-key). A path naming any other organization answers `404`. ## List proxy keys ```http GET /api/v1/management/organizations/{organization_id}/proxy-credentials ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Response** - `credentials` (ProxyKey[]): Every proxy key, oldest first, revoked keys included. - `id` (string): The key's ID. - `name` (string): The key's name. - `environment` (string): `production`, `staging` or `development`. - `region` (string): The key's data region. - `prefix` (string): The start of the secret, such as `fly_px_us_production`. - `last4` (string): The last four characters of the secret. - `status` (string): `active` or `revoked`. - `last_used_at` (string): When the key last authenticated a request. - `expires_at` (string): When the key stops working. - `created_at` (string): When the key was issued. - `revoked_at` (string): When the key was revoked. ## Issue a proxy key ```http POST /api/v1/management/organizations/{organization_id}/proxy-credentials ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Body** - `name` (string, required): What the key is called. - `environment` (string, required): The environment of the public keys it will forward: `production`, `staging` or `development`. Answers `201` with `credential`, a proxy key as above, and `secret`, the whole key, shown once. ## Revoke a proxy key ```http POST /api/v1/management/organizations/{organization_id}/proxy-credentials/{proxy_credential_id}/revoke ``` Authentication: Management key (`x-api-key: fly_mk_…`) Refuses the key from the next request on, and answers with the revoked key. ## Example request ```bash cURL curl -X POST "https://us.api.fingerly.io/api/v1/management/organizations/01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37/proxy-credentials" \ -H "x-api-key: $FINGERLY_MANAGEMENT_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Cloudflare Worker", "environment": "production" }' ``` ## Example response ```json 201 { "credential": { "id": "01a0a861-4d2e-7a90-b1c3-5e6f7a8b9c0d", "name": "Cloudflare Worker", "environment": "production", "region": "us", "prefix": "fly_px_us_production", "last4": "M2dT", "status": "active", "created_at": "2026-09-16T09:20:03Z" }, "secret": "fly_px_us_production_…" } ``` ```json 403 { "error": { "code": "insufficient_role", "message": "only an owner or an admin may manage proxy integrations", "status": 403 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` --- # Webhook endpoints > Create, update, pause and delete webhook endpoints, rotate their signing secrets, send test events and redeliveries, and read delivery attempts. Register the endpoints Fingerly sends [webhooks](https://docs.fingerly.io/docs/webhooks) to. Admin and developer management keys can do all of this. An organization can have up to 10 endpoints. **Path parameter** - `organization_id` (string, required): Your organization, from [Describe the key](https://docs.fingerly.io/reference/management/overview#describe-the-key). A path naming any other organization answers `404`. ## List endpoints ```http GET /api/v1/management/organizations/{organization_id}/webhooks ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Response** - `endpoints` (WebhookEndpoint[]): Every endpoint, newest first. - `id` (string): The endpoint's ID. - `url` (string): Where deliveries are sent. - `description` (string): Your description. - `environment` (string): `live` for production traffic, `test` for staging and development. - `events` (string[]): The event types the endpoint receives. - `status` (string): `active`, `paused` or `failing`. - `secret_last4` (string): The last four characters of the signing secret. - `previous_secret_expires_at` (string): During a rotation, when the previous secret stops signing. - `delivered_24h` (integer): Deliveries that succeeded in the last 24 hours. - `failed_24h` (integer): Deliveries that failed in the last 24 hours. - `last_delivery_at` (string): When the endpoint last received a delivery. - `created_at` (string): When the endpoint was created. ## Create an endpoint ```http POST /api/v1/management/organizations/{organization_id}/webhooks ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Body** - `url` (string, required): An `https://` URL that resolves to a public address. Redirects are not followed. - `environment` (string, required): `live` or `test`. - `events` (string[], required): Event types, such as `identification.completed`. `billing.status_changed` needs `live`. - `description` (string, required): To tell endpoints apart. May be empty. Answers `201` with `endpoint` and `secret`, the signing secret, shown once. A request past the limit of 10 endpoints answers `409` with `webhook_limit`. ## Update an endpoint ```http PATCH /api/v1/management/organizations/{organization_id}/webhooks/{webhook_id} ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Body** - `url` (string): A new URL. - `description` (string): A new description. - `environment` (string): `live` or `test`. - `events` (string[]): The complete new list of event types. - `paused` (boolean): `true` pauses the endpoint; `false` resumes it. Send only the fields to change. Answers with the endpoint. ## Delete an endpoint ```http DELETE /api/v1/management/organizations/{organization_id}/webhooks/{webhook_id} ``` Authentication: Management key (`x-api-key: fly_mk_…`) Removes the endpoint and answers `204`. Deliveries stop at once. ## Rotate the signing secret ```http POST /api/v1/management/organizations/{organization_id}/webhooks/{webhook_id}/rotate-secret ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Body** - `previous_secret_valid_hours` (integer, default `24`): How long the previous secret keeps signing deliveries, from `0` to `168`. Answers with `endpoint` and the new `secret`, shown once. See [rotate a secret](https://docs.fingerly.io/docs/webhooks#rotate-a-secret). ## Send a test event ```http POST /api/v1/management/organizations/{organization_id}/webhooks/{webhook_id}/test ``` Authentication: Management key (`x-api-key: fly_mk_…`) Queues one signed `webhook.test` delivery and answers `202` with its `event_id`. A test or redelivery already queued for the endpoint answers `409` with `webhook_busy`. ## Redeliver an event ```http POST /api/v1/management/organizations/{organization_id}/webhooks/{webhook_id}/redeliver ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Body** - `event_id` (string, required): The ID of an event this endpoint received in the last 30 days. Queues one more delivery of the event, with the same `id`, and answers `202`. ## List delivery attempts ```http GET /api/v1/management/organizations/{organization_id}/webhook-deliveries ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Query parameters** - `environment` (string, default `'all'`): `live`, `test` or `all`. - `limit` (integer, default `50`): Attempts per page. - `cursor` (string): The `next_cursor` of the previous page. **Response** - `deliveries` (Delivery[]): Attempts from the last 30 days, newest first: `id`, `endpoint_id`, `endpoint_url`, `event_id`, `event`, `environment`, `status` (`delivered`, `retrying` or `failed`), `response_code`, `duration_ms`, `attempt`, `error` and `occurred_at`. - `next_cursor` (string): Pass it as `cursor` for the next page. Omitted on the last page. ## Example request ```bash cURL curl -X POST "https://us.api.fingerly.io/api/v1/management/organizations/01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37/webhooks" \ -H "x-api-key: $FINGERLY_MANAGEMENT_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://risk.example.com/webhooks/fingerly", "description": "Risk review queue", "environment": "live", "events": [ "identification.completed", "visitor.suspect" ] }' ``` ## Example response ```json 201 { "endpoint": { "id": "01a0a860-2b7d-7c4e-9f13-8a5b6c7d8e9f", "url": "https://risk.example.com/webhooks/fingerly", "description": "Risk review queue", "environment": "live", "events": [ "identification.completed", "visitor.suspect" ], "status": "active", "secret_last4": "a91F", "delivered_24h": 0, "failed_24h": 0, "created_at": "2026-09-16T09:31:55Z" }, "secret": "whsec_…" } ``` ```json 409 { "error": { "code": "webhook_limit", "message": "an organization may have at most 10 webhook endpoints", "status": 409 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` --- # Risk weights > Read, save and restore suspect-score weights and thresholds for your organization and for individual SDK keys, and list the signals they apply to. Risk weights decide what each signal adds to the suspect score, and the threshold decides where `high` begins. Keep them in version control and apply them from code. See [risk weights](https://docs.fingerly.io/docs/risk-weights) for how profiles are chosen. Any management key can read weights; only **admin** keys can change them. **Path parameters** - `organization_id` (string, required): Your organization, from [Describe the key](https://docs.fingerly.io/reference/management/overview#describe-the-key). A path naming any other organization answers `404`. - `platform` (string): `web`, `android` or `ios`, where a path has it. - `sdk_key_id` (string): The SDK key, where a path has it. ## List signals ```http GET /api/v1/management/signals/catalogue ``` Authentication: Management key (`x-api-key: fly_mk_…`) Every signal group and the signals in it, with the platforms each applies to and its default weights, the default threshold and the maximum weight. The same for every organization. Weights are set against the signal names listed here. ## Read the organization's weights ```http GET /api/v1/management/organizations/{organization_id}/risk-weights ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Response** - `scope` (string): `organization`. - `max_weight` (integer): The largest weight accepted, `10000`. - `platforms` (PlatformWeights[]): One entry per platform. - `platform` (string): `web`, `android` or `ios`. - `scope` (string): `organization`, or `default` when nothing has been saved. - `stored` (boolean): Whether this scope has its own saved configuration. - `revision` (integer): Send it back to detect concurrent edits. - `suspect_threshold` (integer): The threshold. - `weights` (object): Signal name to weight. - `defaults` (object): The shipped default for each signal. - `signals` (string[]): The signals this platform can weight. - `vpn_weights_mode` (string): `method` or `confidence`. - `residential_proxy_weights_mode` (string): `method` or `confidence`. - `updated_at` (string): When the configuration was last saved. ## Save the organization's weights ```http PUT /api/v1/management/organizations/{organization_id}/risk-weights/{platform} ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Body** - `weights` (object): Signal name to weight, from `0` to `10000`. Replaces the stored weights. A signal left out uses its default; `0` turns it off. - `suspect_threshold` (integer): The score at which the level becomes `high`, from `1` to `1000000`. - `vpn_weights_mode` (string): `method` or `confidence`. - `residential_proxy_weights_mode` (string): `method` or `confidence`. - `revision` (integer): The `revision` a read returned. The save is refused with `409` and `weights_stale` if the weights changed since. Omit it to overwrite. Every field is optional. Answers with the platform's configuration, which scores the next identification. ## Restore the default weights ```http DELETE /api/v1/management/organizations/{organization_id}/risk-weights/{platform} ``` Authentication: Management key (`x-api-key: fly_mk_…`) Saves the shipped defaults for the platform and answers with them. ## Read an SDK key's weights ```http GET /api/v1/management/organizations/{organization_id}/sdk-keys/{sdk_key_id}/risk-weights ``` Authentication: Management key (`x-api-key: fly_mk_…`) The same shape as the organization's, with `scope` set to `key`. A platform the key has not overridden answers with the organization's values and `stored` set to `false`. ## Save an SDK key's weights ```http PUT /api/v1/management/organizations/{organization_id}/sdk-keys/{sdk_key_id}/risk-weights/{platform} ``` Authentication: Management key (`x-api-key: fly_mk_…`) **Body** - `weights` (object): Signal name to weight, from `0` to `10000`. Replaces the stored weights. A signal left out uses its default; `0` turns it off. - `suspect_threshold` (integer): The score at which the level becomes `high`, from `1` to `1000000`. - `vpn_weights_mode` (string): `method` or `confidence`. - `residential_proxy_weights_mode` (string): `method` or `confidence`. - `revision` (integer): The `revision` a read returned. The save is refused with `409` and `weights_stale` if the weights changed since. Omit it to overwrite. Overrides the organization's profile for this key alone. Because a key belongs to one environment, this is how a new policy is tried on staging keys before it reaches production. ## Remove an SDK key's weights ```http DELETE /api/v1/management/organizations/{organization_id}/sdk-keys/{sdk_key_id}/risk-weights/{platform} ``` Authentication: Management key (`x-api-key: fly_mk_…`) Deletes the key's own configuration, so the key follows the organization's profile again, and answers with what the key is scored under now. ## Example request ```bash Organization curl -X PUT "https://us.api.fingerly.io/api/v1/management/organizations/01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37/risk-weights/web" \ -H "x-api-key: $FINGERLY_MANAGEMENT_KEY" \ -H "Content-Type: application/json" \ -d '{ "suspect_threshold": 25, "weights": { "automation": 12 }, "revision": 3 }' ``` ```bash One SDK key curl -X PUT "https://us.api.fingerly.io/api/v1/management/organizations/01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37/sdk-keys/01a0a7f3-9e05-7a61-b4c7-2d8e0f3a6b19/risk-weights/web" \ -H "x-api-key: $FINGERLY_MANAGEMENT_KEY" \ -H "Content-Type: application/json" \ -d '{ "suspect_threshold": 20 }' ``` ## Example response ```json 200 { "platform": "web", "scope": "organization", "stored": true, "revision": 4, "suspect_threshold": 25, "vpn_weights_mode": "method", "residential_proxy_weights_mode": "method", "weights": { "tor": 14, "automation": 12, "…": "…" }, "defaults": { "tor": 14, "automation": 9, "…": "…" }, "signals": [ "tor", "automation", "…" ], "updated_at": "2026-09-16T10:02:11Z" } ``` ```json 409 { "error": { "code": "weights_stale", "message": "these weights have been changed since you read them", "status": 409 }, "request_id": "01a0a84c-0f11-7a3e-9c2d-4b5e6f708192" } ``` --- # Event envelope > Every Fingerly webhook is an HTTPS POST with the same headers, the same signature scheme and the same versioned JSON envelope around its data. Every webhook Fingerly sends has the same shape: four headers you can act on, and a JSON envelope whose `data` depends on the event type. ## Event types | Type | Sent when | Environments | | --- | --- | --- | | [`identification.completed`](https://docs.fingerly.io/reference/webhooks/identification-completed) | Any identification finishes. | `live`, `test` | | [`visitor.suspect`](https://docs.fingerly.io/reference/webhooks/visitor-suspect) | An identification reaches the `high` level. | `live`, `test` | | [`identification.refused`](https://docs.fingerly.io/reference/webhooks/identification-refused) | An identify request is refused. | `live`, `test` | | [`billing.status_changed`](https://docs.fingerly.io/reference/webhooks/billing-status-changed) | Your organization starts or stops accepting traffic. | `live` | | [`usage.daily_settled`](https://docs.fingerly.io/reference/webhooks/usage-daily-settled) | A day of usage is settled. | `live`, `test` | `webhook.test` is sent only when you choose **Send test event** for an endpoint. You cannot subscribe to it, and its `data` holds only `endpoint_id`. Ignore event types you do not handle. ## Headers - `Content-Type` (string): `application/json`. - `User-Agent` (string): `Fingerly-Webhooks/1.0`. - `X-Fingerly-Event-ID` (string): The envelope's `id`. - `X-Fingerly-Event-Type` (string): The envelope's `type`, so you can route before parsing. - `X-Fingerly-Timestamp` (string): Unix seconds when this attempt was signed. It changes on every retry. - `X-Fingerly-Signature` (string): `sha256=` followed by the lowercase hex HMAC-SHA256 signature. While a [secret is rotated](https://docs.fingerly.io/docs/webhooks#rotate-a-secret), one such value per secret, separated by commas. ## Signature The signature is an HMAC-SHA256 over the timestamp, a full stop, and the raw request body, keyed with the endpoint's signing secret. Use the whole secret, `whsec_` prefix included, as the key. ```text Signature signed_payload = X-Fingerly-Timestamp + "." + raw_body signature = "sha256=" + hex(hmac_sha256(signing_secret, signed_payload)) ``` - Compare signatures in constant time. - Split the header on commas and accept the delivery if any signature matches. - Reject a timestamp more than five minutes from your clock. - Verify the exact bytes you received. Parsing and re-serialising the JSON changes them. ## Envelope - `id` (string): The event's ID, a UUIDv7. The same on every retry: deduplicate on it. Also sent as `X-Fingerly-Event-ID`. - `type` (string): Always ``. - `version` (integer): The envelope version, `1`. - `organization_id` (string): Your organization. - `environment` (string): `live` for production traffic, `test` for staging and development. - `created_at` (string): When the underlying fact happened, RFC 3339 in UTC. Not when it was delivered. - `data` (object): The event's data. Its fields are listed on each event's page. ## Compatibility - New fields may be added to `data` and to the envelope within version `1`. Ignore fields you do not know. - New event types are only delivered to endpoints that subscribe to them. - Do not rely on the order of JSON keys. > **Tip:** Route on `X-Fingerly-Event-Type` and store the raw body first. Processing it from your own queue keeps your endpoint fast and lets you replay events after a bug fix. ## Headers ```http Headers POST /webhooks/fingerly HTTP/1.1 Content-Type: application/json User-Agent: Fingerly-Webhooks/1.0 X-Fingerly-Event-ID: 01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5 X-Fingerly-Event-Type: identification.completed X-Fingerly-Timestamp: 1789551672 X-Fingerly-Signature: sha256=6f1c0a3e9b… ``` ## Payload ```json Body { "id": "01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5", "type": "identification.completed", "version": 1, "organization_id": "01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37", "environment": "live", "created_at": "2026-09-16T09:41:12Z", "data": { "request_id": "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4", "…": "…" } } ``` --- # identification.completed > Sent for every identification that finishes, with the visitor, the confidence, the score and your tag. ```http POST https://your-server.example.com/webhooks/fingerly ``` Authentication: Signed by Fingerly (`x-fingerly-signature`) Sent once for every identification your SDKs complete, whatever its score. Use it to keep your own copy of results, or to act on visitors without polling. > **Note:** Verify the signature before you parse the body, and answer `2xx` within 10 seconds. See [webhooks](https://docs.fingerly.io/docs/webhooks#verify-the-signature). ## Body - `id` (string): The event's ID, a UUIDv7. The same on every retry: deduplicate on it. Also sent as `X-Fingerly-Event-ID`. - `type` (string): Always `identification.completed`. - `version` (integer): The envelope version, `1`. - `organization_id` (string): Your organization. - `environment` (string): `live` for production traffic, `test` for staging and development. - `created_at` (string): When the underlying fact happened, RFC 3339 in UTC. Not when it was delivered. - `data` (object): The identification. - `request_id` (string): The identification's request ID. - `sdk_key_id` (string): The public key that submitted it. - `visitor_id` (string): The visitor. - `visitor_is_new` (boolean): Whether the visitor is new to your organization. - `identifiable` (boolean): Whether the device could be identified. - `confidence` (integer): The visitor confidence, 0 to 100. - `state` (string): `enriched` or `unavailable`. - `reason` (string): Why the state is `unavailable`. Empty otherwise. - `score` (integer | null): The suspect score. `null` when not scored. - `level` (string | null): `low`, `medium` or `high`. `null` when not scored. - `tag` (string): Your tag. Empty when none was sent. The signals behind the score are not repeated here. Read them with [Get an event](https://docs.fingerly.io/reference/get-event), or subscribe to [`visitor.suspect`](https://docs.fingerly.io/reference/webhooks/visitor-suspect), which includes them. ## Headers ```http Headers POST /webhooks/fingerly HTTP/1.1 Content-Type: application/json User-Agent: Fingerly-Webhooks/1.0 X-Fingerly-Event-ID: 01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5 X-Fingerly-Event-Type: identification.completed X-Fingerly-Timestamp: 1789551672 X-Fingerly-Signature: sha256=6f1c0a3e9b… ``` ## Payload ```json Body { "id": "01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5", "type": "identification.completed", "version": 1, "organization_id": "01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37", "environment": "live", "created_at": "2026-09-16T09:41:12Z", "data": { "request_id": "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4", "sdk_key_id": "01a0a7f3-9e05-7a61-b4c7-2d8e0f3a6b19", "visitor_id": "X9pL2mRc7KvT4bQw8NdF", "visitor_is_new": false, "identifiable": true, "confidence": 100, "state": "enriched", "reason": "", "score": 37, "level": "high", "tag": "checkout:8412" } } ``` --- # visitor.suspect > Sent when an identification reaches the high suspect level, with the score, your threshold and every signal behind it. ```http POST https://your-server.example.com/webhooks/fingerly ``` Authentication: Signed by Fingerly (`x-fingerly-signature`) Sent alongside `identification.completed` when an identification lands at the `high` level: its score reached the threshold in force when it was scored. Subscribe to this alone if you only act on high-risk visitors. > **Note:** Verify the signature before you parse the body, and answer `2xx` within 10 seconds. See [webhooks](https://docs.fingerly.io/docs/webhooks#verify-the-signature). ## Body - `id` (string): The event's ID, a UUIDv7. The same on every retry: deduplicate on it. Also sent as `X-Fingerly-Event-ID`. - `type` (string): Always `visitor.suspect`. - `version` (integer): The envelope version, `1`. - `organization_id` (string): Your organization. - `environment` (string): `live` for production traffic, `test` for staging and development. - `created_at` (string): When the underlying fact happened, RFC 3339 in UTC. Not when it was delivered. - `data` (object): The high-risk identification. - `request_id` (string): The identification's request ID. - `sdk_key_id` (string): The public key that submitted it. - `visitor_id` (string): The visitor. - `score` (integer): The suspect score. - `level` (string): Always `high`. - `threshold` (integer): The threshold the score was compared with. - `triggers` (array): Every signal that fired, heaviest first. - `signal` (string): The signal. - `group` (string): The signal's group. - `weight` (integer): The weight it added. - `confidence` (string): `low`, `medium` or `high`. - `tag` (string): Your tag. Empty when none was sent. ## Headers ```http Headers POST /webhooks/fingerly HTTP/1.1 Content-Type: application/json User-Agent: Fingerly-Webhooks/1.0 X-Fingerly-Event-ID: 01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5 X-Fingerly-Event-Type: visitor.suspect X-Fingerly-Timestamp: 1789551672 X-Fingerly-Signature: sha256=6f1c0a3e9b… ``` ## Payload ```json Body { "id": "01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5", "type": "visitor.suspect", "version": 1, "organization_id": "01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37", "environment": "live", "created_at": "2026-09-16T09:41:12Z", "data": { "request_id": "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4", "sdk_key_id": "01a0a7f3-9e05-7a61-b4c7-2d8e0f3a6b19", "visitor_id": "X9pL2mRc7KvT4bQw8NdF", "score": 37, "level": "high", "threshold": 30, "triggers": [ { "signal": "tor", "group": "tor", "weight": 14, "confidence": "high" }, { "signal": "automation", "group": "bot", "weight": 9, "confidence": "medium" }, { "signal": "tampering", "group": "browser_tampering", "weight": 8, "confidence": "medium" }, { "signal": "high_activity", "group": "high_activity", "weight": 6, "confidence": "low" } ], "tag": "checkout:8412" } } ``` --- # identification.refused > Sent when an identify request is refused: an expired or revoked key, an origin that is not allowed, billing, rate limiting or no credit. ```http POST https://your-server.example.com/webhooks/fingerly ``` Authentication: Signed by Fingerly (`x-fingerly-signature`) Sent for each identify request Fingerly refuses for a reason you can fix. Visitors whose requests are refused are not identified, so this is the event to alert on. > **Note:** Verify the signature before you parse the body, and answer `2xx` within 10 seconds. See [webhooks](https://docs.fingerly.io/docs/webhooks#verify-the-signature). ## Body - `id` (string): The event's ID, a UUIDv7. The same on every retry: deduplicate on it. Also sent as `X-Fingerly-Event-ID`. - `type` (string): Always `identification.refused`. - `version` (integer): The envelope version, `1`. - `organization_id` (string): Your organization. - `environment` (string): `live` for production traffic, `test` for staging and development. - `created_at` (string): When the underlying fact happened, RFC 3339 in UTC. Not when it was delivered. - `data` (object): The refusal. - `request_id` (string): An ID for the refused request. - `sdk_key_id` (string): The key that was used. - `reason` (string): Why it was refused. See below. - `status` (integer): The HTTP status the client received. - `retry_after_seconds` (integer): For `rate_limited`: seconds until the client may retry. Omitted otherwise. ## Reasons | `reason` | `status` | What to do | | --- | --- | --- | | `expired_key` | `401` | Issue a new key and deploy it. | | `revoked_key` | `401` | A revoked key is still in use somewhere. Deploy its replacement. | | `origin_not_allowed` | `401` | Add the site to the key's allowed origins, or find who is using your key. | | `billing_blocked` | `402` | Resolve billing in the dashboard. | | `no_credit` | `402` | Add funds, or turn on auto top-up. | | `rate_limited` | `429` | Traffic exceeded your rate limit. Contact support if it is expected. | > **Note:** Requests with a key Fingerly does not recognise at all are not attributable to an organization, so they send no webhook. ## Headers ```http Headers POST /webhooks/fingerly HTTP/1.1 Content-Type: application/json User-Agent: Fingerly-Webhooks/1.0 X-Fingerly-Event-ID: 01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5 X-Fingerly-Event-Type: identification.refused X-Fingerly-Timestamp: 1789551672 X-Fingerly-Signature: sha256=6f1c0a3e9b… ``` ## Payload ```json origin_not_allowed { "id": "01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5", "type": "identification.refused", "version": 1, "organization_id": "01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37", "environment": "live", "created_at": "2026-09-16T09:41:12Z", "data": { "request_id": "01a0a852-77d0-7b1c-a3e4-5f60718293a4", "sdk_key_id": "01a0a7f3-9e05-7a61-b4c7-2d8e0f3a6b19", "reason": "origin_not_allowed", "status": 401 } } ``` ```json rate_limited { "id": "01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5", "type": "identification.refused", "version": 1, "organization_id": "01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37", "environment": "live", "created_at": "2026-09-16T09:41:12Z", "data": { "request_id": "01a0a852-77d0-7b1c-a3e4-5f60718293a5", "sdk_key_id": "01a0a7f3-9e05-7a61-b4c7-2d8e0f3a6b19", "reason": "rate_limited", "status": 429, "retry_after_seconds": 2 } } ``` --- # billing.status_changed > Sent when your organization's billing status changes, including whether it is accepting traffic. Live endpoints only. ```http POST https://your-server.example.com/webhooks/fingerly ``` Authentication: Signed by Fingerly (`x-fingerly-signature`) Sent when your organization's billing status changes. Only endpoints listening to the `live` environment can subscribe. > **Note:** Verify the signature before you parse the body, and answer `2xx` within 10 seconds. See [webhooks](https://docs.fingerly.io/docs/webhooks#verify-the-signature). ## Body - `id` (string): The event's ID, a UUIDv7. The same on every retry: deduplicate on it. Also sent as `X-Fingerly-Event-ID`. - `type` (string): Always `billing.status_changed`. - `version` (integer): The envelope version, `1`. - `organization_id` (string): Your organization. - `environment` (string): `live` for production traffic, `test` for staging and development. - `created_at` (string): When the underlying fact happened, RFC 3339 in UTC. Not when it was delivered. - `data` (object): The change. - `previous_status` (string): The status before the change. - `current_status` (string): The status now. - `accepting_traffic` (boolean): Whether identifications are accepted in the current status. - `blocked_reason` (string | null): Why the organization is blocked, when it is. `null` otherwise. ## Statuses | Status | Accepts traffic | Meaning | | --- | --- | --- | | `free` | Yes | No payment method on file. Traffic is paid from credit. | | `active` | Yes | A payment method is on file. | | `past_due` | Yes | A payment failed. Update the payment method before the account is blocked. | | `blocked` | No | Identify requests are refused with `402 billing_blocked`. | ## Headers ```http Headers POST /webhooks/fingerly HTTP/1.1 Content-Type: application/json User-Agent: Fingerly-Webhooks/1.0 X-Fingerly-Event-ID: 01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5 X-Fingerly-Event-Type: billing.status_changed X-Fingerly-Timestamp: 1789551672 X-Fingerly-Signature: sha256=6f1c0a3e9b… ``` ## Payload ```json Body { "id": "01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5", "type": "billing.status_changed", "version": 1, "organization_id": "01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37", "environment": "live", "created_at": "2026-09-16T09:41:12Z", "data": { "previous_status": "free", "current_status": "active", "accepting_traffic": true, "blocked_reason": null } } ``` --- # usage.daily_settled > Sent once a UTC day of usage is settled, with the day's checks and what they cost, for live and test traffic. ```http POST https://your-server.example.com/webhooks/fingerly ``` Authentication: Signed by Fingerly (`x-fingerly-signature`) Sent after each UTC day is settled, shortly after midnight UTC: one event for `live` traffic and one for `test` traffic. Use it to reconcile your own records with what you were charged. > **Note:** Verify the signature before you parse the body, and answer `2xx` within 10 seconds. See [webhooks](https://docs.fingerly.io/docs/webhooks#verify-the-signature). ## Body - `id` (string): The event's ID, a UUIDv7. The same on every retry: deduplicate on it. Also sent as `X-Fingerly-Event-ID`. - `type` (string): Always `usage.daily_settled`. - `version` (integer): The envelope version, `1`. - `organization_id` (string): Your organization. - `environment` (string): `live` for production traffic, `test` for staging and development. - `created_at` (string): When the underlying fact happened, RFC 3339 in UTC. Not when it was delivered. - `data` (object): The settled day. - `date` (string): The UTC day, `YYYY-MM-DD`. - `checks` (integer): Identify requests that day. - `billable_checks` (integer): Checks that were billable. Always `0` for `test`. - `refused_checks` (integer): Requests refused for rate limiting or credit. - `billed_micros` (integer): What the billable checks cost, in millionths of a US dollar. - `charged_micros` (integer): What was charged to your balance at settlement, in millionths of a US dollar. `test` sums your staging and development keys. Divide micros by 1,000,000 for dollars: `3000` micros is $0.003. ## Headers ```http Headers POST /webhooks/fingerly HTTP/1.1 Content-Type: application/json User-Agent: Fingerly-Webhooks/1.0 X-Fingerly-Event-ID: 01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5 X-Fingerly-Event-Type: usage.daily_settled X-Fingerly-Timestamp: 1789551672 X-Fingerly-Signature: sha256=6f1c0a3e9b… ``` ## Payload ```json Body { "id": "01a0a851-0c4e-7f23-b8d1-6e2f94c0a7b5", "type": "usage.daily_settled", "version": 1, "organization_id": "01a0a7f2-3c18-7b40-8d2e-5f6a9b1c0d37", "environment": "live", "created_at": "2026-09-16T09:41:12Z", "data": { "date": "2026-09-15", "checks": 48213, "billable_checks": 47655, "refused_checks": 0, "billed_micros": 141327500, "charged_micros": 141327500 } } ``` --- # JavaScript agent > Every export, option, result field and error of the Fingerly browser SDK, @fingerly/web-js. This is the complete API of `@fingerly/web-js`. For a guided introduction, start with the [JavaScript SDK](https://docs.fingerly.io/docs/sdks/javascript). ```ts Usage import { load } from '@fingerly/web-js' const fingerly = await load({ apiKey: 'fly_pk_us_production_…' }) const result = await fingerly.identify({ tag: 'login' }) ``` ## Exports | Export | Kind | Description | | --- | --- | --- | | `load` | Function | Creates a client. | | `FingerlyClient` | Class | The client `load` returns. | | `TransportError` | Class | The error `identify` rejects with. | | `IdentifyOptions`, `IdentifyResult`, `IdentifyTrigger`, `Verdict`, `Verdicts`, `Confidence` | Types | Described below. | The script-tag build, `dist/fingerly.global.js`, exposes the same members on `window.Fingerly`. ## load() ```ts Signature function load(options: FingerlyOptions): Promise ``` Creates a client. Loading collects and sends nothing. A configuration mistake throws synchronously, so call it with `await` inside `try`. **Load options** - `apiKey` (string, required): Your public key. Its prefix decides which regional API is used. - `endpoints` (string | string[]): Ordered base URLs or root-relative paths to send requests to instead of the regional API, for a [proxy](https://docs.fingerly.io/docs/proxy-integrations). `/api/v1/identify` is appended to each. They are tried in order when one fails. - `endpoint` (string): A single base URL or path. Prefer `endpoints`. Passing both throws. - `fallbackToDefaultEndpoint` (boolean, default `false`): Try the key's regional API after the custom endpoints fail. - `consent` ('granted' | 'pending' | 'denied', default `'granted'`): Whether the visitor has consented. Until it is `granted`, `identify()` and `collect()` collect and send nothing and reject with a `ConsentError`. See [consent](https://docs.fingerly.io/docs/privacy-and-consent#consent). - `transport` (object): Replaces how requests are sent, for tests: an object whose `submit(submission, signal)` resolves with an [identify response](https://docs.fingerly.io/reference/identify#response), and optionally `submitSupplement(requestId, submission, signal)`. `endpoint`, `endpoints` and `fallbackToDefaultEndpoint` are then ignored. See [testing](https://docs.fingerly.io/docs/testing#replace-the-transport). - `sources` (array): The signal sources to run. Every browser source by default; pass `[]` in tests to collect nothing. - `schedule` (object): Collection tuning for `collect()` and for `identify()` with explicit `tiers`: `budgetMs`, `defaultSourceTimeoutMs` and `concurrency`. - `budgetMs` (number, default `1200`): The total collection budget. - `defaultSourceTimeoutMs` (number, default `300`): The time any one source may take. - `concurrency` (number, default `6`): Sources collected at once. ### Where requests go | Key prefix | API | | --- | --- | | `fly_pk_us_…` | `https://us.api.fingerly.io` | | `fly_pk_eu_…` | `https://eu.api.fingerly.io` (coming soon) | `load` throws when `apiKey` is empty, when both `endpoint` and `endpoints` are set, when `endpoints` is empty, or when an endpoint is neither an absolute `http(s)` URL nor a path starting with `/`, or contains a query or fragment. ## identify() ```ts Signature fingerly.identify(options?: IdentifyOptions): Promise ``` Collects signals, submits them and resolves with the server's verdict. Each call is a new identification with its own idempotency key; the SDK does not cache results. **IdentifyOptions** - `tag` (string): Your own reference for this identification. Echoed on the event, in webhooks and in the dashboard. - `submit` (boolean, default `true`): `false` collects and computes local verdicts without sending anything. - `signal` (AbortSignal): Cancels collection and submission. - `tiers` (Array<'fast' | 'deferred'>): Collect these tiers in one pass under `schedule.budgetMs`, with no separate deferred report. Omit for the default: a 300 ms initial tier, then a 1,200 ms deferred tier after the answer. ## IdentifyResult - `requestId` (string): The identification's ID. Empty when not submitted. - `visitorId` (string): The stable visitor identifier. Empty only when not submitted or on a duplicate. - `visitorIsNew` (boolean): Whether your organization is seeing this visitor for the first time. - `visitorConfidence` (number): 0 to 100: `100` for an exact match, `85` to `99` when recognised after the device changed, `0` for a new visitor ID. - `identifiable` (boolean): `false` when the browser gave too little to identify anyone. Still scored. - `duplicate` (boolean): `true` when the server had already answered this request. Keep the first response. - `state` (string): `enriched`, or `unavailable` when the network lookup could not run. Empty when not submitted. - `suspectScore` (number | null): The weighted sum of the signals that fired. `null` when not scored, which is not `0`. Not a percentage and not capped. - `suspectLevel` (string | null): `low`, `medium` or `high`. `null` exactly when `suspectScore` is. - `triggers` (IdentifyTrigger[]): The signals that fired, heaviest first. - `signal` (string): The signal. - `group` (string): Its group. - `weight` (number): The weight it added. - `confidence` (string): `low`, `medium` or `high`. - `verdicts` (Verdicts): Local verdicts from the initial tier. Advisory. - `report` (SignalReport): The report that was sent. - `deferred` (Promise): The deferred tier's outcome. Never rejects. ## Verdicts | Key | Matches when | | --- | --- | | `incognito` | The page is in a private browsing window. | | `shields` | Anti-fingerprinting protections are rewriting values. A privacy choice, not a reason to challenge anyone. | | `tor` | The browser is Tor Browser. | | `emulator` | The browser runs in an emulated device. | | `automation` | The browser is driven by automation. | | `farm` | The browser looks mass-provisioned or reset fresh. | **Verdict** - `value` (boolean): Whether the verdict matched. - `confidence` ('low' | 'medium' | 'high'): How strong the evidence is. - `reasons` (string[]): Short, stable tokens for the evidence, strongest first. > **Note:** Verdicts in the browser can be edited by whoever controls the browser. The server runs its own detection over the stored report, and its score is the one to act on. See [client-side verdicts](https://docs.fingerly.io/docs/client-verdicts). ## DeferredIdentifyResult | `status` | Fields | When | | --- | --- | --- | | `'submitted'` | `report`, `combinedReport`, `verdicts` | The deferred report was sent. `verdicts` covers both tiers. | | `'skipped'` | `reason`: `'not-submitted'`, `'explicit-tiers'`, `'duplicate'` | No deferred report applied. | | `'failed'` | `error`, and whatever was collected | Collection or sending failed. The identification itself is unaffected. | ## collect() ```ts Signature fingerly.collect(options?: IdentifyOptions): Promise ``` Runs collection and returns the report without submitting anything. Uses `tiers` (both by default), `signal` and `schedule.budgetMs`. ## setConsent() ```ts Signature fingerly.setConsent(state: 'granted' | 'pending' | 'denied'): void fingerly.consent: 'granted' | 'pending' | 'denied' fingerly.onConsentChange(listener: (state) => void): () => void ``` Changes the consent state. It applies to the next call and to any call already running: withdrawing consent stops collection and sending, and a running deferred tier resolves as `skipped` with the reason `consent-withdrawn`. `onConsentChange` calls its listener on every change and returns a function that unsubscribes. Any other value throws a `TypeError`. ## ConsentError ```ts Definition declare class ConsentError extends Error { readonly name: 'ConsentError' readonly code: 'consent_required' readonly state: 'pending' | 'denied' } ``` `identify()` and `collect()` reject with it when consent is not `granted`, or is withdrawn while they run. ## TransportError ```ts Definition declare class TransportError extends Error { readonly name: 'TransportError' readonly status?: number // the HTTP status, when the server answered readonly retryable: boolean // whether trying again later may succeed } ``` | Situation | `status` | `retryable` | | --- | --- | --- | | The server refused the request | The HTTP status | `true` for `404`, `405`, `408`, `425`, `429` and `5xx` | | The network failed or an attempt timed out | none | `true` | | The request was aborted | none | `false` | | A `2xx` without a request ID, such as a captive portal page | none | `false` | `identify` rejects only after its own retries: up to three attempts, 5 seconds each, with jittered backoff and one idempotency key. ## Runtime - ES2020, using `fetch` and `AbortController`. No workers, WebAssembly or `eval` are required. - Requests omit credentials and set no cookies. The SDK stores no identifier on the device. - About 18 KB gzipped for the script-tag build.