Fingerly tells you who a visitor is and how suspicious the session looks. What to do about it depends on what the visitor is trying to do. Each recipe below takes one kind of fraud from the client to a decision on your server, with a policy you can start from.
| Recipe | Identify at | What decides it |
|---|---|---|
| Account takeover | Login, password reset, account changes, payouts | Whether the account has used this device before, and its level. |
| Credential stuffing | Every login attempt | Automation, and how many failed attempts and accounts one device is behind. |
| Sign-up abuse | Account creation | How many accounts one device has created, and device farm and emulator signals. |
| Promotion abuse | Redeeming a code, a referral or a trial | One redemption per device per promotion. |
| Payment fraud | Checkout, adding a card | The level, anonymised networks, and how many cards one device tries. |
What every recipe shares
- A tag per action. The tag binds an identification to what it was made for, so a request ID from a harmless page cannot be spent at checkout.
- A server-side read. Decisions use the event your backend reads with a secret key, never what the client reports.
- Your own records. Fingerly returns the visitor ID; counting accounts, failed logins or redemptions per visitor happens in your database. Store
visitor_idwith every account, login, order and redemption. - Failure is missing evidence. When identification fails, the visitor continues and your server decides with less information.
The shared server helper
Every recipe starts the same way: read the stored event with a secret key, and refuse to trust it unless it belongs to this action, is recent, and has not been used before. Fingerly does not stop a request ID from being read twice, so the one-time check is yours: any store with an atomic "add if absent" works, such as Redis SET with NX and a ten-minute expiry.
// fingerly.server.ts
import { load, FingerlyAPIError } from '@fingerly/node'
const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! })
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
async function readEvent(requestId: string) {
for (let attempt = 1; ; attempt++) {
try {
return await fingerly.events.get(requestId)
} catch (error) {
const status = error instanceof FingerlyAPIError ? error.status : undefined
if (status === 404 && attempt < 4) await sleep(attempt * 250) // not readable yet
else if (status === 404 || status === 422) return null
else throw error
}
}
}
/**
* The stored event behind a request ID, or null when there is nothing to
* trust: no ID, an unknown ID, another action's tag, too old, or used before.
*/
export async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) {
if (typeof requestId !== 'string' || requestId === '') return null
const event = await readEvent(requestId)
if (!event || event.tag !== tag) return null
if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null
if (!(await usedRequestIds.add(event.request_id))) return null // your store: true only the first time
return event
}
/** Whether any of these signal groups fired. */
export const fired = (event: { triggers: Array<{ signal: string }> }, ...groups: string[]) =>
event.triggers.some((trigger) => groups.includes(trigger.signal))
verifiedEvent returns nothing when the client could not identify the visitor at all. Treat that as missing evidence: the policies below add friction a real customer can pass, rather than refusing outright.