Skip to main content

fingerly

Server-side event reads, webhook verification and a same-origin proxy for Python. Two clients share one API: Fingerly for synchronous code such as Django and Flask, and AsyncFingerly for asyncio code such as FastAPI and Starlette. Python 3.9 or newer. One runtime dependency, httpx. The proxy serves both ASGI and WSGI, so it mounts under either.

import os
from fingerly import Fingerly

fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])
page = fingerly.events.list(level="high", limit=50)
event = fingerly.events.get(page.rows[0].request_id)
import asyncio
import os
from fingerly import AsyncFingerly


async def main() -> None:
    async with AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) as fingerly:
        page = await fingerly.events.list(level="high", limit=50)
        for row in page.rows:
            print(row.request_id, row.suspect_score)


asyncio.run(main())

In a web app, build the client once and reuse it: see Framework guides for FastAPI, Django and Flask.

Event reads require fly_sk_{production|staging|development}_… keys. Requests go to https://api.fingerly.io; endpoint overrides that for a local API. Public, proxy, management, malformed and unsupported keys are rejected before network access. Requests do not follow redirects. get and list each perform one request and never retry.

FingerlyAPIError provides status, and optional code, details, request_id and retry_after, the seconds a 429, a 503 or a 404 event_pending asks you to wait (None when the response did not say). The secret key is stripped from all of them. Every status outside 2xx raises it, a redirect included, since requests never follow one. FingerlyTimeoutError and FingerlyConnectionError cover a deadline and an unreachable API; all three derive from FingerlyError.

ip_network is the network the request came from (an IPv4 /24 or an IPv6 /40, such as 203.0.113.0/24); the API never returns the address. document, when available, is an allow-listed view of the archived request with no raw signals, user agent or address. suspect_score is None when the request was not scored, and must not be treated as zero. Event declares every documented field as an attribute and falls back to the decoded body for fields added later, so a new server field needs no SDK upgrade.

project_id is the UUID string of the Fingerly project the event belongs to, on both events.get and the rows of events.list; it is None when an older server does not send it.

Read an event you just identified

Send the requestId the browser SDK returned to your server and read it there with fingerly.events.get(request_id), or await fingerly.events.get(request_id) with AsyncFingerly. A 404 carries one of two codes:

  • event_pending raises FingerlyEventPendingError, a FingerlyAPIError: the request id was accepted but its event is not recorded yet. It is not a verdict. Try again after retry_after seconds, or verify asynchronously, for example from a webhook. The SDK never retries on its own.
  • event_not_found raises a plain FingerlyAPIError: the request id was never issued for this key's environment, or the event is past the 30-day retention.

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

Verify a backend action

Read the event with a server-only key. Check that its tag matches your action, that it is recent, that its environment is correct, and that it was scored. Apply your own policy to the server's verdict. Do not authorize an action using scores or visitor identifiers supplied by the browser. Tags correlate events; they do not replace your application's authentication or replay protection. A tag must never hold a personal identifier, such as a user ID, email, install ID or advertising ID, because the server keeps tags in long-lived rollups. A FingerlyEventPendingError means the server has not decided yet: retry after retry_after seconds or verify asynchronously, and do not refuse the action as fraud on that answer alone. The framework guides below answer it with 503 and Retry-After.

Webhooks

import os

from flask import Flask, abort, request

from fingerly import verify_webhook

app = Flask(__name__)


@app.post("/webhooks/fingerly")
def fingerly_webhook():
    if not verify_webhook(
        secret=os.environ["FINGERLY_WEBHOOK_SECRET"],
        payload=request.get_data(),
        timestamp=request.headers.get("x-fingerly-timestamp"),
        signature=request.headers.get("x-fingerly-signature"),
    ):
        abort(400)
    delivery = request.get_json()
    print(delivery["type"])
    return "", 204

identification.completed and visitor.suspect deliveries carry the event's project as data["project_id"]; identification.refused carries it only when the refused request named a known key. The SDK passes the payload through unmodelled, so read it from the parsed JSON.

Verify the exact raw bytes before JSON parsing. Signatures cover the timestamp and body. The default replay tolerance is 300 seconds; persist processed webhook IDs to deduplicate deliveries. During signing-secret rotation, any matching signature in the comma-separated header is accepted; only the first 32 are read. The function never raises for malformed input: bad input is a failed verification, not an exception.

Closing

Each client holds pooled connections. Build one and reuse it; build one per event loop for AsyncFingerly.

Never call Fingerly from async def code. It blocks the event loop for the whole request, so every other request on that worker waits behind it: five reads against a slow API stall a FastAPI worker for five timeouts in a row. Use AsyncFingerly there, or a plain def route, which FastAPI and Starlette run in a thread. Fingerly raises a RuntimeWarning, once per process, when it finds itself on a running event loop.

Fingerly AsyncFingerly
Calls fingerly.events.get(id) await fingerly.events.get(id)
Closing fingerly.close() or with Fingerly(...) as fingerly: await fingerly.aclose() or async with AsyncFingerly(...) as fingerly:

Same-origin proxy

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

Create a proxy in the dashboard (Integration > Proxy keys). It issues the proxy key and a random path, such as /k7x2qm4a, for you to serve it on. One FingerlyProxy holds the configuration and exposes both server standards, so a project running each gets the same behaviour from one instance.

from fingerly import FingerlyProxy

proxy = FingerlyProxy(
    proxy_key=os.environ["FINGERLY_PROXY_KEY"],
    path="/k7x2qm4a",
    client_ip=lambda request: (
        request.headers.get("cf-connecting-ip") or request.peer_host
    ),
)

path is the path the dashboard issued, within your application. With it set, the proxy answers that one path, exactly, and anything else gets 404, including anything under it, a trailing slash, another letter case, a dot segment or a percent-encoded spelling of the same path. It excludes anything a sub-URI deployment adds, and a path the mount has already stripped works too, so the same configuration serves an app at the root and one served from /shop. It is optional: leave it out and the proxy answers whatever your framework routes to it, which is safe only where the framework routes that one path and nothing else. There is no default path. Use the one the dashboard issued rather than inventing one: blockers look for words such as fingerprint, tracking and fingerly.

Serve proxy.asgi under an async framework (FastAPI, Starlette, Django's ASGI stack, Quart, Litestar) and proxy.wsgi under a sync one (Flask, Django's WSGI stack, Pyramid). Under Starlette and FastAPI use app.add_route, not a Mount: Starlette answers a mount's bare path with a redirect to its slashed form, the browser SDK posts to the bare one, and the proxy does not answer the slashed one. The framework guides show each in full.

Then point the browser SDK at the same path:

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

What the proxy does

The browser SDK posts both of its calls, the identification and the report that follows it, to that one path. The proxy forwards each, as it is, to Fingerly's one ingest URL, https://api.fingerly.io/api/v1/ingest, and to nothing else. A path other than the configured one gets 404, and any method but POST gets 405. Event reads need a secret key and stay unreachable from a browser by construction.

The browser must present a public key from the same environment as the proxy key, or the request gets 401. Bodies over 1 MiB get 413, enforced while streaming rather than after buffering, and under WSGI a body the server cannot read, such as broken chunked framing, gets 400. Responses are marked no-store.

Only the content type, the visitor metadata Fingerly needs, and an idempotency-key are forwarded. Customer cookies and authorization headers are never passed on. The proxy reports itself upstream as python/<version> in x-fingerly-proxy-version, which the dashboard shows per proxy, and sends fingerly-python/<version> as its user agent.

When Fingerly cannot be reached in time the proxy answers 504 Gateway timeout, and on any other upstream failure, such as a refused connection, a malformed or truncated response or one over 4 MiB, 502 Bad gateway. Both are short plain text marked no-store, and each writes one line, with no stack trace, to the fingerly.proxy logger.

Back to the browser it relays only content-type, x-request-id, ratelimit-limit, ratelimit-remaining and retry-after, plus its own cache-control: no-store and a content-length for the decoded body. A content blocker can match a response header by name, so an allowlist is what keeps anything naming Fingerly off the page.

client_ip is the one thing you must get right. Resolve it from a header your own infrastructure sets, such as your load balancer's or CDN's, and fall back to peer_host, the socket peer. Trust that header only when traffic can reach your app solely through that CDN or load balancer; otherwise a visitor can send it themselves. Never read the left-most X-Forwarded-For value: the visitor writes it. The callback is synchronous, because it is called under both standards.

The callback must return exactly one IPv4 or IPv6 address, such as 203.0.113.7 or 2001:db8::1. Anything else, including None, an empty string, a list, an address with a port, or an exception, is treated as a configuration mistake: the request is not forwarded, the visitor gets 500 with the one line The client IP callback returned no valid address, and the fingerly.proxy logger reports it once per process.

ProxyRequest gives the callback headers (case-insensitive), path, method, peer_host, mount and raw_path. peer_host is the socket peer, which is your load balancer rather than the visitor whenever one sits in front of you.

A note on mount paths

Both adapters match the path as the mounted application sees it: PATH_INFO under WSGI, and under ASGI the path with root_path removed when it is still carrying it. Mounted at its own path, the proxy finds that path in the mount instead, SCRIPT_NAME or root_path, with nothing after it.

That last part needs care. ASGI servers disagree about what path holds under a mount: Starlette 0.47 and newer leave it whole and report the prefix separately in root_path, while older versions and some routers strip the prefix off it. Both are permitted by the spec, so the proxy detects which one it was given rather than assuming.

Taking the application-relative path rather than the whole one is what makes a sub-URI deployment work. There, root_path or SCRIPT_NAME carries a prefix that belongs to the deployment and that your path knows nothing about, so a proxy matching on the whole path would refuse every request. It is the same rule Rack's PATH_INFO and ASP.NET Core's Request.Path already follow.

tests/test_frameworks.py drives real Starlette and Flask apps, mounted plainly and again under a sub-URI, to keep all of it that way.

Closing

The proxy opens its upstream connections on first use, one pool per standard. Close whichever you used when your app shuts down: proxy.close() for WSGI, await proxy.aclose() for ASGI. A WSGI process can simply exit.

Framework guides

Each guide is a complete app. Replace /k7x2qm4a with the path the dashboard issued, and set FINGERLY_SECRET_KEY and FINGERLY_PROXY_KEY. The browser posts identifications to the proxy path and sends the requestId it gets back to /verify/<request_id>, where your server reads the event with its secret key.

FastAPI

# main.py; run with: uvicorn main:app
import contextlib
import os

from fastapi import FastAPI, HTTPException, Request

from fingerly import (
    AsyncFingerly,
    FingerlyAPIError,
    FingerlyEventPendingError,
    FingerlyProxy,
)

proxy = FingerlyProxy(
    proxy_key=os.environ["FINGERLY_PROXY_KEY"],
    path="/k7x2qm4a",
    client_ip=lambda request: (
        request.headers.get("cf-connecting-ip") or request.peer_host
    ),
)


@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
    # One read client per worker, built on the worker's own event loop.
    app.state.fingerly = AsyncFingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])
    yield
    await app.state.fingerly.aclose()
    await proxy.aclose()


app = FastAPI(lifespan=lifespan)
app.add_route("/k7x2qm4a", proxy.asgi)


@app.get("/verify/{request_id}")
async def verify(request_id: str, request: Request):
    try:
        event = await request.app.state.fingerly.events.get(request_id)
    except FingerlyEventPendingError as error:
        # Not decided yet, not fraud: ask the caller to come back.
        retry_after = str(round(error.retry_after or 2))
        raise HTTPException(503, headers={"Retry-After": retry_after}) from None
    except FingerlyAPIError as error:
        raise HTTPException(404 if error.status == 404 else 502) from None
    return {"visitor_id": event.visitor_id, "suspect_score": event.suspect_score}

The same works under uvicorn --workers 4 or gunicorn's uvicorn worker: each worker runs the lifespan and gets its own client and pool.

Django

Django has no add_route, so put the proxy in front of Django in wsgi.py or asgi.py, before any URL routing. In a project named mysite:

# mysite/identification.py
import os

from fingerly import Fingerly, FingerlyProxy

PROXY_PATH = "/k7x2qm4a"

proxy = FingerlyProxy(
    proxy_key=os.environ["FINGERLY_PROXY_KEY"],
    path=PROXY_PATH,
    client_ip=lambda request: (
        request.headers.get("cf-connecting-ip") or request.peer_host
    ),
)

# One read client per process, shared by every thread.
fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])
# mysite/wsgi.py, for gunicorn mysite.wsgi or runserver
import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")

from mysite.identification import PROXY_PATH, proxy  # noqa: E402

django_application = get_wsgi_application()


def application(environ, start_response):
    if environ.get("PATH_INFO") == PROXY_PATH:
        return proxy.wsgi(environ, start_response)
    return django_application(environ, start_response)
# mysite/asgi.py, for uvicorn mysite.asgi:application
import os

from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")

from mysite.identification import PROXY_PATH, proxy  # noqa: E402

django_application = get_asgi_application()


async def application(scope, receive, send):
    if scope["type"] == "http" and scope["path"] == PROXY_PATH:
        await proxy.asgi(scope, receive, send)
    else:
        await django_application(scope, receive, send)
# mysite/views.py
from django.http import JsonResponse

from fingerly import FingerlyAPIError, FingerlyEventPendingError
from mysite.identification import fingerly


def verify(request, request_id):
    try:
        event = fingerly.events.get(request_id)
    except FingerlyEventPendingError as error:
        # Not decided yet, not fraud: ask the caller to come back.
        retry_after = str(round(error.retry_after or 2))
        return JsonResponse(
            {"error": error.code}, status=503, headers={"Retry-After": retry_after}
        )
    except FingerlyAPIError as error:
        status = 404 if error.status == 404 else 502
        return JsonResponse({"error": error.code}, status=status)
    return JsonResponse(
        {"visitor_id": event.visitor_id, "suspect_score": event.suspect_score}
    )
# mysite/urls.py
from django.urls import path

from mysite import views

urlpatterns = [path("verify/<str:request_id>", views.verify)]

verify is a plain view, so Django runs it in a thread under ASGI too. In an async def view use AsyncFingerly instead, built once per process.

Flask

# app.py; run with: gunicorn app:app, or flask --app app run
import os

from flask import Flask, abort
from werkzeug.middleware.dispatcher import DispatcherMiddleware

from fingerly import (
    Fingerly,
    FingerlyAPIError,
    FingerlyEventPendingError,
    FingerlyProxy,
)

app = Flask(__name__)
fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])
proxy = FingerlyProxy(
    proxy_key=os.environ["FINGERLY_PROXY_KEY"],
    path="/k7x2qm4a",
    client_ip=lambda request: (
        request.headers.get("cf-connecting-ip") or request.peer_host
    ),
)
app.wsgi_app = DispatcherMiddleware(app.wsgi_app, {"/k7x2qm4a": proxy.wsgi})


@app.get("/verify/<request_id>")
def verify(request_id):
    try:
        event = fingerly.events.get(request_id)
    except FingerlyEventPendingError as error:
        # Not decided yet, not fraud: ask the caller to come back.
        retry_after = str(round(error.retry_after or 2))
        return {"error": error.code}, 503, {"Retry-After": retry_after}
    except FingerlyAPIError as error:
        abort(404 if error.status == 404 else 502)
    return {"visitor_id": event.visitor_id, "suspect_score": event.suspect_score}

A DispatcherMiddleware mount answers everything under its path too, which is what path narrows back down to the one path.

Timeouts

timeout is one deadline over the whole request: connecting, sending, the response headers and the body. An API that answers a byte at a time cannot stretch it. The default is 10 seconds for the clients and 5 for the proxy. Response bodies over 4 MiB are refused rather than buffered: the clients raise FingerlyAPIError with code response_too_large.

Under a thread or process server (gunicorn sync or gthread workers, Django or Flask under WSGI), size the pool for the slow case, not the average one: while Fingerly is slow, every proxied request holds its thread for the whole timeout. At 100 proxied requests a second with 1% of them running into a 5 second timeout, that is 5 threads busy on average, before any fast request is served. Run more threads than that, lower timeout for the proxy, or serve through proxy.asgi, where a waiting request holds no thread.

Connections

Each client, and each proxy handler, keeps its connections open between requests and shares them across threads or tasks. Up to 256 concurrent requests per process reuse connections rather than opening one each; pass limits=httpx.Limits(max_connections=…) to change that. Connections are replaced every five minutes, so a changed DNS answer is picked up. Redirects are never followed and no cookies are kept.

Development and release preparation

uv sync                                     # 3.9 per .python-version, dev group included
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv run pytest
uv run python scripts/sync_contract.py --check
uv build                                    # wheel and sdist

uv sync reads the dev dependency group, so there is nothing else to install. .python-version pins the development interpreter to 3.9, the oldest supported runtime, so the floor is exercised locally rather than only in CI. uv.lock is committed; CI installs with uv sync --frozen.

The vendored synthetic contract snapshot makes tests independent of sibling repositories. To update it from the integration workspace, set FINGERLY_CONTRACT_DIR to its contract directory and run python scripts/sync_contract.py. Checks verify the recorded hashes. Never put production secrets in fixtures.

Source structure

_client.py holds both clients and their event reads. _http.py is the connection pool both clients and the proxy send through, with its deadline and body cap. _transport.py builds requests and interprets responses, so the two clients share one definition of what a request is and what a failure means. _models.py owns Event, EventPage and their parsing. _webhooks.py verifies signatures. _proxy.py owns every proxy decision, with _asgi.py and _wsgi.py as thin adapters that only read a request and write a response, so the two standards cannot drift apart. _config.py validates every caller-supplied option: keys, origins, API versions and durations, all before any network access. _errors.py holds the exception hierarchy. _version.py holds the version everything else reads. __init__.py only exposes the public API.

API version

Pass api_version to select a route version, for example v1 (the default) or v2. The server must support the chosen version. The version applies to every API request; it is separate from the endpoint origin. On the proxy it selects the ingest URL, /api/v2/ingest, and nothing on your own site.

Metadata

Release files for fingerly 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for fingerly 0.2.0
File Size Uploaded Provenance Actions
fingerly-0.2.0.tar.gz 42.0 kB Without provenance Details for fingerly-0.2.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for fingerly 0.2.0
File Interpreter ABI Platform Actions
fingerly-0.2.0-py3-none-any.whl Python 3 none any Details for fingerly-0.2.0-py3-none-any.whl

Total release size: 89.5 kB

Release files / fingerly-0.2.0.tar.gz

Download URL fingerly-0.2.0.tar.gz
Size 42.0 kB
Tags Source
SHA-256 checksum
How to use checksums
51197f4e7dabdcc4825ecb4c928fae628e63cf6aa5f0c6fd15160bdac3f2d258
BLAKE2b-256 checksum
How to use checksums
43c9fb8dc8d4a19aa3544368821a58049d6a1e71506af470b461de96bc4153cc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / fingerly-0.2.0-py3-none-any.whl

Download URL fingerly-0.2.0-py3-none-any.whl
Size 47.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
40c85c74c22789c2e35e9d0e6542ee20efc4dca1123fc8e15102039e7ad8ffbd
BLAKE2b-256 checksum
How to use checksums
4604074924e5a856503cbea80c5c9d052ebef657ca0a2ffb00b188c93d8c8a3d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for fingerly 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page