Guides

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.

Current versions

Upgrading from a preview build

0.1.0 is the first release of every SDK. Builds of the SDKs and proxies from before 25 September 2026 differ from the current versions in the five ways below, and each of them can stop identifications until you act. If you start with the current versions, none of this applies: the guides already describe how they work.

ChangeAffectsWhat to do
Consent defaults to pendingEvery client SDK, and anything that posts to the API itselfGrant consent once you have it, and send "consent": "granted" from your own code.
Endpoints are posted to exactly as givenThe JavaScript SDK and its bindings, with custom endpointsSet endpoints to the full path your proxy answers.
The script-tag build is renamedPages that load the script-tag buildServe dist/browser.global.js instead of dist/fingerly.global.js.
Proxies answer one exact pathServer SDK proxies, the Cloudflare Worker and proxies of your ownReplace the prefix with the path the dashboard issued, and forward to /api/v1/ingest.
Forwarded visitor headers need a proxy keyProxies that forward the visitor's addressMake sure every proxy sends its proxy key.

Every client SDK now starts with its consent state pending instead of granted, and reads and sends nothing until it is granted: the JavaScript SDK and its script-tag build, the React, Next.js, Vue, Nuxt, Svelte and Angular bindings, iOS, Android, and the React Native, Flutter and Lynx packages. Every identification and every deferred report carries "consent": "granted", and the API answers a submission without it with 422 and the code consent_required, on POST /ingest, POST /identify and POST /events/{request_id}/supplement.

  1. Find every place your pages and apps load a Fingerly SDK.
  2. If you already have consent, or rely on another lawful basis, pass granted when loading, as in the table below.
  3. Otherwise keep the default, and call setConsent with granted from your consent tool once the visitor agrees. See consent tools.
  4. If you post to the API without an SDK, add "consent": "granted" to every identification and deferred report body.
  5. Expect the consent error from identify while consent is not granted, and treat it as "not identified yet" rather than as a failure.
import { load } from '@fingerly/web-js'

// Consent used to default to granted, so this identified straight away.
const fingerly = await load({ apiKey: 'fly_pk_production_…' })
const result = await fingerly.identify()
SDKGrant consentError while pending
JavaScript and the script-tag buildload({ apiKey, consent: 'granted' }), or setConsent('granted')ConsentError
React and Next.js<FingerlyProvider consent="granted">, or setConsent from useFingerly()ConsentError
VuecreateFingerly({ apiKey, consent: 'granted' })ConsentError
Nuxtconsent: 'granted' in the fingerly options, or NUXT_PUBLIC_FINGERLY_CONSENT=grantedConsentError
SveltesetupFingerly({ apiKey, consent: 'granted' })ConsentError
AngularprovideFingerly({ apiKey, consent: 'granted' })ConsentError
iOSFingerly.load(apiKey:consent: .granted), or setConsent(.granted)ConsentError
AndroidFingerly.load(app, apiKey = …, consent = ConsentState.GRANTED), or setConsent(ConsentState.GRANTED)ConsentException
React Native and Lynxload({ apiKey, consent: 'granted' }), or setConsent('granted')ConsentError
FlutterFingerly.load(apiKey: …, consent: ConsentState.granted), or setConsent(ConsentState.granted)FingerlyConsentException
Your own HTTP client"consent": "granted" in the body422 with consent_required

In the JavaScript SDK, a 422 consent_required from the API is thrown as the same ConsentError. See privacy and consent.

Endpoints are posted to exactly as given

The JavaScript SDK used to treat each endpoints entry as a base: it appended /api/v1/identify for the identification and /api/v1/events/{request_id}/supplement for the deferred report. It now posts both calls to each entry exactly as given, with nothing appended. An entry that is a bare origin, such as http://localhost:8099, is read as an API and gets /api/v1/ingest. Without endpoints, both calls go to https://api.fingerly.io/api/v1/ingest.

  1. Replace each endpoints entry with the exact URL your proxy answers: the path the dashboard issued, such as /k7x2qm4a, or an absolute URL for a proxy on another host.
  2. Leave entries that name an API by its origin alone as they are.
  3. Rename endpoint to endpoints. endpoint still works and means the same, but it is deprecated.
  4. Stop importing IDENTIFY_PATH from @fingerly/core. It is no longer exported.
  5. Update your proxy in the same deployment, as described in proxies answer one exact path.
import { load } from '@fingerly/web-js'

// Posted to /api/fingerly/api/v1/identify,
// then /api/fingerly/api/v1/events/{request_id}/supplement
const fingerly = await load({ apiKey: 'fly_pk_production_…', endpoints: '/api/fingerly' })

On the API, POST /ingest is new. It takes both calls and tells them apart by the deferred report's token: an identification is answered 200 with the verdict, a deferred report 202. POST /identify and POST /events/{request_id}/supplement remain for direct callers. The mobile SDKs now post their identifications to /api/v1/ingest too, under the API origin they are configured with; in the iOS and Android SDKs, the IDENTIFY_PATH constant is replaced by INGEST_PATH.

The script-tag build is renamed

The script-tag build in @fingerly/web-js is now dist/browser.global.js, renamed from dist/fingerly.global.js, because filter lists match script file names too. It still defines window.Fingerly. Fingerly does not host the file for you: copy the new file to wherever you serve it, under a neutral name of your own. If you load it from a package CDN, change the file name in that URL as well.

cp node_modules/@fingerly/web-js/dist/fingerly.global.js public/assets/app-core.js

Proxies answer one exact path

Every proxy now answers exactly one path, the random one the dashboard issues when you create a proxy in Integration > Proxy keys, and forwards every request to /api/v1/ingest. There is no default path any more: the server SDKs' /api/fingerly default is gone. A proxy relays back only content-type, x-request-id, RateLimit-Limit, RateLimit-Remaining and Retry-After, and proxied responses no longer carry Fingerly-Balance-Micros.

  1. Open Integration > Proxy keys and note the proxy's path. A proxy key created before this change keeps working; edit it to record how it runs and its path, or create a new proxy, which issues a path.
  2. Upgrade the server SDK, the Worker or the template, and replace the prefix option with the path, as in the table below.
  3. Route that one path to the proxy. With a path set, it answers 404 to every other path, including paths under it.
  4. Set the browser SDK's endpoints to the same path.
  5. If you run a proxy of your own, forward the body unchanged to https://api.fingerly.io/api/v1/ingest whatever path it arrived on, and relay only the headers above. See proxy integrations.
  6. If your pages read Fingerly-Balance-Micros through a proxy, read the balance in the dashboard instead.
SDKBeforeAfter
Node.jscreateProxy({ prefix })createProxy({ path }), optional where the route already sends only that path
PythonFingerlyProxy(prefix=…)FingerlyProxy(path=…); under Starlette, add_route rather than mount
GoProxyOptions{Prefix: …}ProxyOptions{Path: …}; leave it empty behind http.StripPrefix
Java.prefix(…).path(…), required by the WebFlux filter
.NEToptions.Prefixoptions.Path, required by UseFingerlyProxy; or give the path to MapFingerlyProxy
PHP'prefix' => …'path' => …, required by the PSR-15, Laravel and Symfony adapters. prefix is now refused as an unknown option.
Rubyprefix:path:, required when the proxy is middleware in front of an app
Rust.prefix(…).path(…) with route_service; leave it unset under nest_service, which strips the path
import { Fingerly } from '@fingerly/node'

export const POST = Fingerly.createProxy({
  proxyKey: process.env.FINGERLY_PROXY_KEY!,
  prefix: '/api/fingerly',
  clientIp: (request) => request.headers.get('x-real-ip') ?? '',
})

The Cloudflare Worker proxy 0.2.0 takes the path as FINGERLY_PATH, which replaces FINGERLY_ROUTE_PREFIX, and its route is that one path. FINGERLY_INTEGRATION_ID is removed, and FINGERLY_ALLOWED_ORIGINS is needed only when the route is on another host than your pages.

routes = [{ pattern = "example.com/neutral-random-path/*", zone_name = "example.com" }]

[vars]
FINGERLY_ROUTE_PREFIX = "/neutral-random-path"
FINGERLY_ALLOWED_ORIGINS = "https://www.example.com,https://example.com"

Forwarded visitor headers need a proxy key

A request that carried x-fingerly-client-ip, x-fingerly-origin or x-fingerly-user-agent without a proxy key used to be answered as a direct call, which recorded the proxy's own address against every visitor. It is now refused with 401 and the code proxy_key_missing. A request authenticated with a secret key may still forward them.

  1. Check that every proxy sends its proxy key: the Worker's FINGERLY_PROXY_KEY secret, the server SDK's proxy key option, the key in a template's configuration, or the origin header on AWS CloudFront.
  2. After deploying, look for proxy_key_missing in your proxy's responses. See troubleshooting.

Other changes on 25 September 2026

These need no action, but you may notice them.

Changed

  • Apps are recognised by the SDK's stored install ID only, never by device hardware, which is used for detections and scoring only. See visitor identification.
  • Android keeps its install record in a file excluded from Auto Backup and device-to-device transfer instead of shared preferences, and moves an existing record there once. A reinstall, or a restore onto another phone, now starts a new install ID and so a new visitor ID.
  • iOS no longer reads boot time, uptime or disk space, sends no DeviceCheck token, reports only a count of installed fonts and a summary of accessibility settings, and reports the container's age in whole days instead of the app's install date.
  • Android reports other apps only as yes or no, never by package name.
  • IP coordinates are stored rounded to one decimal place, so every event records an approximate location.
  • Archived submissions expire after 90 days. See data retention.
  • The JavaScript SDK no longer asks for a user-agent client hint that the specification deprecates.
  • endpoint in the JavaScript SDK is deprecated in favour of endpoints, which means the same.

Added

  • POST /ingest, the one URL the JavaScript SDK and every proxy post both calls to.
  • deleteStoredIdentity() deletes the install record on the device, for account deletion and erasure: Fingerly.deleteStoredIdentity() on iOS, Fingerly.deleteStoredIdentity(context) on Android, and deleteStoredIdentity() in the React Native, Flutter and Lynx packages.
  • The iOS SDK ships a privacy manifest, PrivacyInfo.xcprivacy, that declares no tracking and file timestamps (C617.1) as its only required-reason API. See App Store and Google Play privacy.
  • Each proxy key records how the proxy runs, its path, and the version, origin and 30-day request count it last reported, and its name, kind and path can be changed.
  • Ready-made proxy templates, and hosted subdomains for sites that can run no proxy.

Fixed

  • Identifications from the iOS and Android SDKs no longer create a new visitor every time.
  • Safari with its advanced fingerprinting protection on, as in a Private window, is recognised as one visitor across page loads instead of a new visitor on each.

API

v1

The first version of the API.

  • Client API: POST /ingest, the one URL the browser SDK and first-party proxies post both calls to, plus POST /identify, POST /events/{request_id}/supplement and POST /attestation/challenge, with public keys.
  • Every identification and deferred report carries "consent": "granted"; anything else is answered 422 with consent_required.
  • Server API: GET /events and GET /events/{request_id}, with secret keys.
  • Proxy integrations: proxy keys with a kind and a random path each, forwarded visitor headers, and x-fingerly-proxy-version, with the last version, origin and 30-day request count shown per proxy.
  • Proxy kinds for the ready-made templates: nginx, caddy, vercel, netlify, aws_cloudfront, gcp_cloud_run, traefik and container, beside cloudflare_worker, server_sdk and custom.
  • Forwarded visitor headers sent without a proxy key are refused with 401 and proxy_key_missing, rather than recorded with the proxy's own address.
  • Hosted subdomains, a fallback for sites that can run no proxy: one CNAME to Fingerly, a certificate issued automatically, and the management routes to list, add, check and remove them.
  • 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.

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, 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, posted to exactly as given, with fallbackToDefaultEndpoint.
  • A script-tag build, dist/browser.global.js, defining window.Fingerly.
  • Consent states: consent, defaulting to pending, 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, defaulting to pending, and setConsent.
  • Recognition by the stored install ID only, and deleteStoredIdentity().
  • A privacy manifest in the iOS SDK. See App Store and Google Play privacy.

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.
  • Python: a synchronous and an asyncio client, and FingerlyProxy, a first-party proxy serving both ASGI and WSGI.
  • Go: a context-aware client and NewProxy, a first-party proxy as an http.Handler.
  • Java: a client for the JVM and FingerlyProxy, a first-party proxy with Jakarta, javax and WebFlux adapters.
  • .NET: an async client and FingerlyProxy, a first-party proxy for ASP.NET Core endpoints or middleware.
  • Ruby: a client for Ruby 3.2 and newer and Fingerly::Proxy, a first-party proxy as Rack middleware.
  • PHP: a client with no Composer dependencies and Fingerly\Proxy, a first-party proxy with Laravel, Symfony and PSR-15 adapters.
  • Rust: an async client and FingerlyProxy, a first-party proxy as a tower::Service.
  • Every proxy answers one path, the one the dashboard issued, set with an optional path option, and relays back only an allowlist of response headers.

Cloudflare Worker proxy

0.2.0

  • Answers exactly one path, FINGERLY_PATH, the one the dashboard issued, which replaces FINGERLY_ROUTE_PREFIX. FINGERLY_INTEGRATION_ID is removed.
  • Forwards every request to /api/v1/ingest, and relays back only content-type, x-request-id and the rate limit headers.
  • FINGERLY_ALLOWED_ORIGINS is needed only for a route on another host than your pages.
  • Reports itself upstream as cloudflare-worker/0.2.0.

0.1.0

The first version of the Worker proxy: forwards identify and deferred-report requests for allowed origins, authenticated with a proxy key.

Proxy templates

1.0.0

The first ready-made proxies for sites that run neither the Cloudflare Worker nor a server SDK. The dashboard prints each one filled in with the proxy's own path and key.

  • Vercel Routing Middleware, a Netlify Edge Function, and an AWS CloudFront Function with its cache behavior and a CloudFormation fragment.
  • A Google Cloud Run service behind an external Application Load Balancer. Firebase Hosting rewrites are not supported.
  • An nginx location block and a Caddy handle block.
  • A container for Docker, Kubernetes, HAProxy, Envoy or any load balancer, with FINGERLY_CLIENT_IP_SOURCE set to remote, header:<name> or xff:<n>, and Traefik routes to it.
  • Each answers exactly the issued path and only POST, forwards to /api/v1/ingest alone, resolves the visitor's address from a source the visitor cannot write, and relays back only content-type, x-request-id and the rate limit headers.