SDKs

Rust

Read identification events with a secret key, verify signed webhooks, and serve the browser SDK from your own domain, with an async Tokio client and a tower service.

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

Terminal
cargo add fingerly

Read an event

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

Rust
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.

Rust
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.

Rust
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.

FeatureDefaultAdds
clientyesFingerly and event reads, over reqwest and Tokio.
proxyyesFingerlyProxy, a tower::Service. Implies client.
rustlsyesTLS through rustls.
native-tlsnoTLS through the platform's own stack instead.
timenoOffsetDateTime accessors for event timestamps and list windows.
Cargo.toml
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.

decide.rs
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.

read.rs
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.

proxy.rs
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>required
    A 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()DurationDefault 5s
    Deadline per request, covering the response body.
  • .http_client_builder()reqwest::ClientBuilder
    A 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.

MethodReturnsCalls
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)boolNever 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.