# Node.js

> Read identification events with a secret key, verify signed webhooks, and serve the browser SDK from your own domain with the Fingerly Node.js SDK.

Last updated: 2026-09-17

`@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`, `Request` and `Response` (Bun, Deno, Cloudflare Workers, Vercel Functions).
- A [secret key](https://docs.fingerly.io/docs/api-keys) for reading events, a webhook signing secret, and a [proxy key](https://docs.fingerly.io/docs/proxy-integrations) for the proxy.

## Install

```bash npm
npm install @fingerly/node
```

```bash pnpm
pnpm add @fingerly/node
```

```bash yarn
yarn add @fingerly/node
```

## Read an event

Create one client with your secret key and reuse it. The key decides the regional API and the environment the client reads.

```ts Node.js
import { load } from '@fingerly/node'

const fingerly = load({ 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](https://docs.fingerly.io/reference/get-event#response). `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](https://docs.fingerly.io/docs/server-side-verification).

```ts Node.js
import { load, FingerlyAPIError } from '@fingerly/node'

const fingerly = load({ 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.

```ts Node.js
import express from 'express'
import { verifyWebhook } from '@fingerly/node'

app.post('/webhooks/fingerly', express.raw({ type: 'application/json' }), async (req, res) => {
  const valid = 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

`createProxy` returns a `(request: Request) => Promise<Response>` handler that forwards the browser SDK's identify and deferred-report requests to Fingerly with the visitor's real address, origin and user agent. Mount it on any framework that speaks web `Request` and `Response`.

```ts Next.js
// app/metrics/[...path]/route.ts
import { createProxy } from '@fingerly/node'

export const POST = createProxy({
  proxyKey: process.env.FINGERLY_PROXY_KEY!,     // fly_px_us_production_…
  prefix: '/metrics',
  clientIp: (request) => request.headers.get('x-real-ip') ?? '',
})
```

```ts Hono
import { Hono } from 'hono'
import { createProxy } from '@fingerly/node'

const proxy = createProxy({
  proxyKey: process.env.FINGERLY_PROXY_KEY!,
  prefix: '/metrics',
  clientIp: (request) => request.headers.get('cf-connecting-ip') ?? '',
})

const app = new Hono()
app.post('/metrics/*', (c) => proxy(c.req.raw))
```

```ts Browser
import { load } from '@fingerly/web-js'

const fingerly = await load({ apiKey: 'fly_pk_us_production_…', endpoints: '/metrics' })
```

> **Warning:** Resolve `clientIp` from a header your own infrastructure sets, such as your load balancer's or CDN's. Never use the left-most `X-Forwarded-For` value: the visitor can write it.

## API

**`load(options)`**

- `secretKey` (string, required): A secret key. Its prefix decides the regional API.
- `endpoint` (string): Override the API origin.
- `fetchImpl` (typeof fetch, default `globalThis.fetch`): A custom `fetch`.

| Method | Returns | Calls |
| --- | --- | --- |
| `events.list(query?)` | `Promise<EventPage>` | [`GET /events`](https://docs.fingerly.io/reference/list-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}`](https://docs.fingerly.io/reference/get-event). |

A non-2xx response throws `FingerlyAPIError` with the HTTP `status`. Network errors and aborts are thrown as they are.

**`verifyWebhook(options)`, returns `boolean`**

- `secret` (string, required): The endpoint's signing secret, `whsec_…`.
- `payload` (string | Uint8Array, required): The raw request body, byte for byte.
- `timestamp` (string | null, required): The `x-fingerly-timestamp` header.
- `signature` (string | null, required): The `x-fingerly-signature` header.
- `toleranceSeconds` (number, default `300`): How far the timestamp may be from now.

**`createProxy(options)`**

- `proxyKey` (string, required): A proxy key, `fly_px_…`. Its prefix decides the regional API.
- `clientIp` ((request: Request) => string | Promise<string>, required): Resolves the visitor's address from infrastructure you trust.
- `prefix` (string, default `'/api/fingerly'`): The path the proxy is mounted under.
- `maxBodyBytes` (number, default `1048576`): Larger bodies are refused with `413`.
- `timeoutMs` (number, default `5000`): How long to wait for Fingerly.
- `upstream` (string): Override the API origin.

The proxy answers `405` to anything but `POST`, `404` to any path except `{prefix}/api/v1/identify` and `{prefix}/api/v1/events/{request_id}/supplement`, and `401` when the request carries no public key.
