io.fingerly:fingerly-server reads stored events by request ID, verifies webhook signatures, and forwards the browser SDK's requests through a route on your own domain. Its only runtime dependency is jackson-databind.
Requirements
- Java 11 or newer. Kotlin and Scala use the same artifacts.
- A secret key for reading events, a webhook signing secret, and a proxy key for the proxy.
Install
<dependency>
<groupId>io.fingerly</groupId>
<artifactId>fingerly-server</artifactId>
<version>0.1.0</version>
</dependency>
Read an event
Create one client with your secret key and reuse it. The key names the environment the client reads.
import io.fingerly.server.FingerlyClient;
FingerlyClient fingerly = FingerlyClient.builder()
.secretKey(System.getenv("FINGERLY_SECRET_KEY"))
.build();
Event 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.
public String decide(String orderId, String requestId) {
Event event;
try {
event = fingerly.events().get(requestId);
} catch (FingerlyApiException e) {
if (e.getStatus() == 404) return "refuse";
throw e;
}
if (!("checkout:" + orderId).equals(event.getTag())) return "refuse";
if (event.getOccurredAt().isBefore(Instant.now().minus(Duration.ofMinutes(2)))) return "refuse";
return switch (String.valueOf(event.getSuspectLevel())) {
case "high" -> "review";
case "medium" -> "challenge";
default -> "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.
@PostMapping("/webhooks/fingerly")
public ResponseEntity<Void> receive(
@RequestBody byte[] body,
@RequestHeader("x-fingerly-timestamp") String timestamp,
@RequestHeader("x-fingerly-signature") String signature) {
if (!Webhooks.verify(webhookSecret, body, timestamp, signature)) {
return ResponseEntity.badRequest().build();
}
WebhookEvent event = Webhooks.parse(body);
events.enqueue(event.getId(), body); // deduplicate on the event ID
return ResponseEntity.noContent().build();
}
Artifacts
Java has no single HTTP server standard, so the proxy ships an adapter per standard. Take the core plus whichever one matches your stack; the servlet APIs and Spring are provided, so nothing collides with what your application server already has.
| Artifact | Contains | For |
|---|---|---|
io.fingerly:fingerly-server | client, webhooks, proxy core | everything |
io.fingerly:fingerly-server-jakarta | FingerlyProxyFilter | Spring Boot 3, Tomcat 10+, Jetty 11+ |
io.fingerly:fingerly-server-javax | FingerlyProxyFilter | Spring Boot 2, Tomcat 9 and older |
io.fingerly:fingerly-server-webflux | FingerlyProxyWebFilter | reactive Spring, 5.3 and 6.x |
Unscored events
Event.getSuspectScore() returns Optional<Integer>, empty when the request was not scored. Never read an empty score as zero: a visitor about whom nothing fired scores 0, while a submission that was never enriched has no score at all. isScored() states the distinction at the call site, and getRaw() keeps the response body so a field the API adds later needs no SDK upgrade.
Read an event you just identified
An event is readable as soon as identification answers, so this needs nothing special.
Event event = fingerly.events().get(requestId);
Earlier versions had a waitFor that polled through the gap between identification and the event becoming queryable. There is no gap, so isEventNotFound() now means a request id that was never issued. The archived document arrives moments after the event itself, so check hasDocument() 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 holds every decision and speaks to no server API. One of the adapters mounts it.
@Bean
FingerlyProxy fingerlyProxy() {
return FingerlyProxy.builder()
.proxyKey(System.getenv("FINGERLY_PROXY_KEY")) // fly_px_production_…
.path("/k7x2qm4a")
.clientIp(request -> request.header("CF-Connecting-IP"))
.build();
}
@Bean
FilterRegistrationBean<FingerlyProxyFilter> fingerlyProxyFilter(FingerlyProxy proxy) {
var registration = new FilterRegistrationBean<>(new FingerlyProxyFilter(proxy));
registration.addUrlPatterns("/k7x2qm4a");
return registration;
}
A filter passes every other path on to your application untouched. 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 java/0.1.0, which the dashboard shows per proxy.
API
FingerlyClient.builder()
secretKeyStringrequiredA secret key. Its prefix names the environment read.endpointStringOverride the API origin.apiVersionStringDefault"v1"API route version; the server must support the selected version.timeoutDurationDefault10sDeadline per request, covering the response body.httpClientHttpClientA custom client. Configure it withRedirect.NEVER.
| Method | Returns | Calls |
|---|---|---|
events().get(requestId) | Event | GET /events/{request_id}. |
events().list(params) | EventPage | GET /events. params may be null; an unset filter is not sent, so the server defaults it. |
Webhooks.verify(secret, payload, timestamp, signature) | boolean | Static. Never throws for malformed input; a five-minute replay window by default. |
Webhooks.parse(payload) | WebhookEvent | The delivery envelope. getData() stays a JsonNode. |
FingerlyProxy.builder() | FingerlyProxy | The proxy core, mounted by one of the adapters. .path(...) is the issued path. |
Every exception is unchecked and derives from FingerlyException. A non-2xx response throws FingerlyApiException with getStatus(), and optional getCode(), getDetails() and getRequestId(); the secret key is stripped from all three. A deadline throws FingerlyTimeoutException and an unreachable API throws FingerlyConnectionException.