@fingerly/node does three things on your server: reads stored events by request ID, verifies webhook signatures, and forwards the browser SDK's requests through a route on your own domain. It has no runtime dependencies.
Requirements
- Node.js 18.16 or newer, or any runtime with global
fetch,RequestandResponse(Bun, Deno, Cloudflare Workers, Vercel Functions). - A secret key for reading events, a webhook signing secret, and a proxy key for the proxy.
Install
npm install @fingerly/node
Read an event
Create one client with your secret key and reuse it. The key names the environment the client reads.
import { Fingerly } from '@fingerly/node'
const fingerly = new Fingerly({ secretKey: process.env.FINGERLY_SECRET_KEY! })
const event = await 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.
import { Fingerly, FingerlyAPIError } from '@fingerly/node'
const fingerly = new Fingerly({ secretKey: process.env.FINGERLY_SECRET_KEY! })
const MAX_AGE_MS = 2 * 60 * 1000
export async function decide(orderId: string, requestId: string) {
let event
try {
event = await fingerly.events.get(requestId)
} catch (error) {
if (error instanceof FingerlyAPIError && error.status === 404) return 'refuse'
throw error
}
if (event.tag !== 'checkout:' + orderId) return 'refuse'
if (Date.now() - Date.parse(event.occurred_at) > MAX_AGE_MS) 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.
import express from 'express'
import { Fingerly } from '@fingerly/node'
app.post('/webhooks/fingerly', express.raw({ type: 'application/json' }), async (req, res) => {
const valid = Fingerly.verifyWebhook({
secret: process.env.FINGERLY_WEBHOOK_SECRET!,
payload: req.body,
timestamp: req.get('x-fingerly-timestamp'),
signature: req.get('x-fingerly-signature'),
})
if (!valid) return res.sendStatus(400)
const event = JSON.parse(req.body.toString('utf8'))
await queue.add(event.id, event) // deduplicate on event.id
res.sendStatus(204)
})
Serve the browser SDK from your domain
Fingerly.createProxy returns a (request: Request) => Promise<Response> handler that forwards the browser SDK's requests to Fingerly with the visitor's real address, origin and user agent. Create a proxy in Integration > Proxy keys, then mount the handler at the path it issued, such as /k7x2qm4a, on any framework that speaks web Request and Response. See proxy integrations.
// app/k7x2qm4a/route.ts
import { Fingerly } from '@fingerly/node'
export const POST = Fingerly.createProxy({
proxyKey: process.env.FINGERLY_PROXY_KEY!, // fly_px_production_…
clientIp: (request) => request.headers.get('x-real-ip') ?? '',
})
The browser SDK posts both of its calls to that path exactly, with nothing appended. Where the handler is not already routed to that one path, pass path: '/k7x2qm4a' and it answers only that path, exactly. Keep the path the dashboard issued: a path of your own is refused if it contains a word blockers match, such as fingerly, tracking or metrics.
API
new Fingerly(options)
secretKeystringrequiredA secret key. Its prefix names the environment read.apiVersionstringDefault'v1'API route version; the server must support the selected version.endpointstringOverride the API origin.fetchImpltypeof fetchDefaultglobalThis.fetchA customfetch.timeoutMsnumberDefault5000Deadline covering the request and response body.
| Method | Returns | Calls |
|---|---|---|
events.list(query?) | Promise<EventPage> | GET /events. query takes from, to (a Date or RFC 3339 string), page, limit, visitor and level, and an optional signal (an AbortSignal) to cancel the request. |
events.get(requestId, signal?) | Promise<Event> | GET /events/{request_id}. |
A non-2xx response throws FingerlyAPIError with status, and optional code, details and requestId. Deadlines throw FingerlyTimeoutError; external cancellation preserves its reason. load, createProxy and verifyWebhook remain compatible function exports.
Fingerly.verifyWebhook(options), returns boolean
secretstringrequiredThe endpoint's signing secret,whsec_….payloadstring | Uint8ArrayrequiredThe raw request body, byte for byte.timestampstring | nullrequiredThex-fingerly-timestampheader.signaturestring | nullrequiredThex-fingerly-signatureheader.toleranceSecondsnumberDefault300How far the timestamp may be from now.
Fingerly.createProxy(options)
proxyKeystringrequiredA proxy key,fly_px_…. Its prefix names the environment it pairs with.clientIp(request: Request) => string | Promise<string>requiredResolves the visitor's address from infrastructure you trust.pathstringThe path the dashboard issued, such as/k7x2qm4a, matched exactly; trailing slashes are ignored. Omit it when the route already sends only that path to the handler.maxBodyBytesnumberDefault1048576Larger bodies are refused with413.timeoutMsnumberDefault5000How long to wait for Fingerly.apiVersionstringDefault'v1'The version of the upstream ingest URL.upstreamstringOverride the API origin.
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. 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 node/0.1.0, which the dashboard shows per proxy.