Guides

Use cases

End-to-end recipes for account takeover, credential stuffing, sign-up abuse, promotion abuse and payment fraud: where to identify, what to tag and what to decide.

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.

RecipeIdentify atWhat decides it
Account takeoverLogin, password reset, account changes, payoutsWhether the account has used this device before, and its level.
Credential stuffingEvery login attemptAutomation, and how many failed attempts and accounts one device is behind.
Sign-up abuseAccount creationHow many accounts one device has created, and device farm and emulator signals.
Promotion abuseRedeeming a code, a referral or a trialOne redemption per device per promotion.
Payment fraudCheckout, adding a cardThe 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_id with 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.