SDKs

Python

Read identification events, verify signed webhooks, and serve the browser SDK from your own domain, with a synchronous and an asyncio client sharing one API.

The fingerly package reads stored events by request ID, verifies webhook signatures, and forwards the browser SDK's requests through a route on your own domain. 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. One runtime dependency, httpx.
  • A secret key for reading events, a webhook signing secret, and a proxy key for the proxy.

Install

pip install fingerly

Read an event

Create one client with your secret key and reuse it. The key names the environment the client reads.

import os
from fingerly import Fingerly

fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])
event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4")

An event has the fields listed in Get an event. suspect_score is null when the request was not scored.

Verify a checkout

Read the event your client identified, check it belongs to this action and is recent, then decide on its level. See server-side verification.

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"

Verify a webhook

Check the signature over the raw request body before parsing it. The helper rejects timestamps more than five minutes from now.

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

Read an event you just identified

An event is readable as soon as identification answers, so this needs nothing special.

event = fingerly.events.get(request_id)

Earlier versions had a wait_for that polled through the gap between identification and the event becoming queryable. There is no gap, so a 404 event_not_found now means a request id that was never issued. The archived document arrives moments after the event itself, so check has_document rather than assuming it is there.

Sync or async

`Fingerly``AsyncFingerly`
Use inDjango, Flask, scripts, Celery tasksFastAPI, Starlette, aiohttp, Quart
Callsfingerly.events.get(id)await fingerly.events.get(id)
HTTP clientOne pooled connection per clientOne pooled connection per client, per event loop
Closingfingerly.close(), or with Fingerly(...) as fingerly:await fingerly.aclose(), or async with AsyncFingerly(...) as fingerly:
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

Fingerly(...) and AsyncFingerly(...)

  • secret_keystrrequired
    A secret key. Its prefix names the environment read.
  • endpointstr | None
    Override the API origin.
  • timeoutfloatDefault 10.0
    Passed to httpx, in seconds, per connect, read, write and pool operation.
  • api_versionstrDefault 'v1'
    API route version; the server must support the selected version.
  • transporthttpx.BaseTransport | None
    A custom httpx transport, for tests or a host with its own HTTP stack.
MethodReturnsCalls
events.get(request_id)EventGET /events/{request_id}.
events.list(from_, to, page, limit, visitor, level)EventPageGET /events. from_ and to accept a datetime or an RFC 3339 string; a naive datetime is read as UTC. page defaults to 1 and limit to 10.

EventPage has rows, page and page_size. Event exposes every documented field as an attribute, with occurred_at parsed to a timezone-aware datetime, and falls back to the decoded body for fields added later.

A non-2xx response raises FingerlyAPIError with status, and optional code, details and request_id; the secret key is stripped from all three. A deadline raises FingerlyTimeoutError and an unreachable API raises FingerlyConnectionError. All three derive from FingerlyError.

verify_webhook(...), returns bool

  • secretstrrequired
    The endpoint's signing secret, whsec_….
  • payloadstr | bytesrequired
    The raw request body, byte for byte.
  • timestampstr | Nonerequired
    The x-fingerly-timestamp header.
  • signaturestr | Nonerequired
    The x-fingerly-signature header.
  • tolerance_secondsfloatDefault 300
    How far the timestamp may be from now.

Synchronous in both clients, because verification is one HMAC over bytes you already have. It never raises for malformed input: bad input is a failed verification, not an exception.

Serve the browser SDK from your domain

Content blockers refuse browser requests to third-party APIs, and a refused identification is a visitor never identified. Mount the proxy at the path the dashboard issued for it, in Integration > Proxy keys, and those requests become first-party. See proxy integrations.

Python has two server standards rather than one, so FingerlyProxy holds the configuration once and exposes both. A project running each gets the same behaviour from one instance.

from fingerly import FingerlyProxy

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

# A route, not a mount: a mount redirects the bare path the SDK posts to.
app.add_route("/k7x2qm4a", proxy.asgi)
StandardAttributeFrameworks
ASGIproxy.asgiFastAPI, Starlette, Django 3+, Quart, Litestar, Sanic
WSGIproxy.wsgiFlask, Django's sync stack, Pyramid, Bottle

FingerlyProxy(...)

  • client_ip(request: ProxyRequest) -> strrequired
    Resolves the visitor's address from infrastructure you trust.
  • proxy_keystrrequired
    A proxy key, fly_px_…. Its prefix names the environment it pairs with.
  • pathstr | NoneDefault None
    The path the dashboard issued, within your application, matched exactly. None answers whatever the framework routes to the proxy.
  • max_body_bytesintDefault 1048576
    Larger bodies are refused with 413, while streaming.
  • timeoutfloatDefault 5.0
    How long to wait for Fingerly, in seconds.
  • api_versionstrDefault 'v1'
    The version of the upstream ingest URL.
  • upstreamstr
    Override the API origin.

The callback receives a ProxyRequest with case-insensitive headers, plus path, method and peer_host. peer_host is the socket peer, so it is your load balancer rather than the visitor whenever one sits in front of you. The same callback works under both standards.

With path set, the proxy answers 404 to any other path. It answers 405 to anything but POST, and 401 unless the browser presents a public key for the same environment as the proxy key. Every request is forwarded to https://api.fingerly.io/api/v1/ingest, and only content-type, x-request-id and the rate limit headers are relayed back, so nothing on the response names Fingerly. Event reads need a secret key and are unreachable through it. Responses are marked no-store, customer cookies and authorization headers are never forwarded, and the proxy reports itself upstream as python/0.1.0, which the dashboard shows per proxy.

Each handler opens its upstream connection on first use. Close whichever you mounted: proxy.close() for WSGI, await proxy.aclose() for ASGI.