fingerly reads stored events by request ID, verifies webhook signatures, and forwards the browser SDK's requests through a route on your own domain. It is asynchronous throughout, on Tokio and reqwest.
Requirements
- Rust 1.88 or newer, and a Tokio runtime.
- A secret key for reading events, a webhook signing secret, and a proxy key for the proxy.
Install
cargo add fingerly
Read an event
Create one client with your secret key and reuse it. The key names the environment the client reads.
use fingerly::Fingerly;
let fingerly = Fingerly::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. 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.
async fn decide(fingerly: &Fingerly, order_id: &str, request_id: &str) -> fingerly::Result<Decision> {
let event = match fingerly.events().get(request_id).await {
Ok(event) => event,
Err(error) if error.status() == Some(404) => return Ok(Decision::Refuse),
Err(error) => return Err(error),
};
if event.tag.as_deref() != Some(&format!("checkout:{order_id}")) {
return Ok(Decision::Refuse);
}
// The time feature parses occurred_at; without it, compare the string.
if OffsetDateTime::now_utc() - event.occurred_at_time()? > Duration::minutes(2) {
return Ok(Decision::Refuse);
}
Ok(match event.suspect_level.as_deref() {
Some("high") => Decision::Review,
Some("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.
async fn fingerly_webhook(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> StatusCode {
// body is the exact bytes; a payload rebuilt from a parsed value is not.
let header = |name| headers.get(name).and_then(|value| value.to_str().ok());
if !fingerly::verify_webhook(
&state.webhook_secret,
&body,
header("x-fingerly-timestamp"),
header("x-fingerly-signature"),
) {
return StatusCode::BAD_REQUEST;
}
let Ok(event) = serde_json::from_slice::<fingerly::WebhookEvent>(&body) else {
return StatusCode::BAD_REQUEST;
};
state.queue.enqueue(event.id, body).await; // deduplicate on the event ID
StatusCode::NO_CONTENT
}
Features
The default features are client, proxy and rustls. Webhook verification sits behind none of them, so a build that only receives webhooks needs neither an HTTP stack nor an async runtime.
| Feature | Default | Adds |
|---|---|---|
client | yes | Fingerly and event reads, over reqwest and Tokio. |
proxy | yes | FingerlyProxy, a tower::Service. Implies client. |
rustls | yes | TLS through rustls. |
native-tls | no | TLS through the platform's own stack instead. |
time | no | OffsetDateTime accessors for event timestamps and list windows. |
fingerly = { version = "0.1", default-features = false } # webhooks only
Deadlines and cancellation
Every request carries a deadline that covers reading the response body, so a server that answers and then stalls times out like one that never answered. It defaults to 5 seconds and is set with .timeout(…) on the builder.
Cancellation is ordinary Rust cancellation: drop the future and the request stops. There is no cancellation token to thread through.
Unscored events
Event::suspect_score is an Option<i32>, because None means unscored and must never be read as zero: a visitor about whom nothing fired scores 0, while a submission that was never enriched has no score at all. score() and is_scored() put that distinction at the call site.
let Some(score) = event.score() else {
// Unscored. Decide on something else; do not treat it as clean.
return Ok(Decision::Review);
};
Event::raw and EventPage::raw keep the response body exactly as it arrived, so a field the API adds later is reachable without an SDK upgrade.
Read an event you just identified
An event is readable as soon as identification answers, so this needs nothing special.
let event = fingerly.events().get(&request_id).await?;
Earlier versions had a wait_for that polled through the gap between identification and the event becoming queryable. There is no gap, so is_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.
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.
FingerlyProxy implements tower::Service over http::Request, which is what axum, hyper, warp and tonic all take, so it needs no per-framework adapter. For anything that does not speak tower, proxy.handle(request).await takes an http::Request and returns an http::Response directly.
use fingerly::FingerlyProxy;
let proxy = FingerlyProxy::builder()
.proxy_key(std::env::var("FINGERLY_PROXY_KEY")?) // fly_px_production_…
.path("/k7x2qm4a")
.client_ip(|parts| {
parts.headers
.get("cf-connecting-ip")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned()
})
.build()?;
let app = axum::Router::new().route_service("/k7x2qm4a", proxy);
With .path(...) set, the proxy answers 404 to any other path. It answers 405 to anything but POST, and 401 when the request carries no 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 rust/0.1.0, which the dashboard shows per proxy.
API
Fingerly::builder(secret_key)
secret_keyimpl Into<String>requiredA secret key. Its prefix names the environment read..endpoint()impl Into<String>Override the API origin..api_version()impl Into<String>Default"v1"API route version; the server must support the selected version..timeout()DurationDefault5sDeadline per request, covering the response body..http_client_builder()reqwest::ClientBuilderA custom HTTP stack. It takes a builder rather than a Client because reqwest fixes its redirect policy at build time, and the SDK sets Policy::none so a redirect can never replay the key.
Fingerly::new(secret_key) is the builder with every default. Both return a Result, so an invalid key or option fails at construction and nothing is sent.
| Method | Returns | Calls |
|---|---|---|
events().get(request_id) | Result<Event> | GET /events/{request_id}. |
events().list(params) | Result<EventPage> | GET /events. An unset filter is not sent, so the server defaults it. |
verify_webhook(secret, payload, timestamp, signature) | bool | Never panics for malformed input. WebhookVerifier::new(secret).tolerance(…) sets the replay window, five minutes by default. |
FingerlyProxy::builder() | Result<FingerlyProxy> | A tower::Service for the browser SDK's submissions, never event reads. .path(...) is the issued path. |
Every call returns fingerly::Result<T>, whose error is one Error enum: Config for an option that was rejected before anything was sent, Api with status and optional code, details and request_id, Timeout with the operation it bounded, Connection, and Decode. The secret key is stripped from everything an error carries, and the response body is never echoed. error.is_event_not_found() matches the one retryable read failure without comparing a status and a code.