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 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: |
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_keystrrequiredA secret key. Its prefix names the environment read.endpointstr | NoneOverride the API origin.timeoutfloatDefault10.0Passed tohttpx, in seconds, per connect, read, write and pool operation.api_versionstrDefault'v1'API route version; the server must support the selected version.transporthttpx.BaseTransport | NoneA custom httpx transport, for tests or a host with its own HTTP stack.
| Method | Returns | Calls |
|---|---|---|
events.get(request_id) | Event | GET /events/{request_id}. |
events.list(from_, to, page, limit, visitor, level) | EventPage | GET /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
secretstrrequiredThe endpoint's signing secret,whsec_….payloadstr | bytesrequiredThe raw request body, byte for byte.timestampstr | NonerequiredThex-fingerly-timestampheader.signaturestr | NonerequiredThex-fingerly-signatureheader.tolerance_secondsfloatDefault300How 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)
| Standard | Attribute | Frameworks |
|---|---|---|
| ASGI | proxy.asgi | FastAPI, Starlette, Django 3+, Quart, Litestar, Sanic |
| WSGI | proxy.wsgi | Flask, Django's sync stack, Pyramid, Bottle |
FingerlyProxy(...)
client_ip(request: ProxyRequest) -> strrequiredResolves the visitor's address from infrastructure you trust.proxy_keystrrequiredA proxy key,fly_px_…. Its prefix names the environment it pairs with.pathstr | NoneDefaultNoneThe path the dashboard issued, within your application, matched exactly.Noneanswers whatever the framework routes to the proxy.max_body_bytesintDefault1048576Larger bodies are refused with413, while streaming.timeoutfloatDefault5.0How long to wait for Fingerly, in seconds.api_versionstrDefault'v1'The version of the upstream ingest URL.upstreamstrOverride 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.