Guides

Payment fraud

Score every checkout, step up risky payments, hold high-risk orders for review, stop card testing per device, and keep evidence for chargeback disputes.

Payment fraud comes in two shapes. Stolen cards are used to buy goods the thief resells, and the real cardholder's chargeback arrives weeks later. Card testing tries long lists of stolen card numbers with small payments to find the ones that still work. Both cost you the goods, the fees, and your standing with your payment provider.

Where to identify

MomentTagWhy
Submitting the paymentcheckout:<order id>The tag ties the identification to one order.
Adding or updating a saved cardadd-cardCard testing often happens here rather than at checkout.
Buying gift cards or digital goodscheckout:<order id>Instantly resellable, so a favourite of stolen cards.

Identify in the client

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

const fingerly = await load({ apiKey: 'fly_pk_us_production_…' })

async function submit() {
  let requestId: string | undefined
  try {
    ({ requestId } = await fingerly.identify({ tag: 'checkout:' + orderId }))
  } catch {
    // Carry on: your server treats a missing request ID as missing evidence.
  }
  await fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orderId, requestId }) })
}

Read the event on your server

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.

Decide

import { verifiedEvent } from './fingerly.server'

export async function checkoutDecision(order: Order, requestId: unknown) {
  const event = await verifiedEvent(requestId, 'checkout:' + order.id)
  if (!event) return 'authenticate'   // 3-D Secure or your equivalent

  // Evidence for a dispute, kept with the order for as long as you keep orders.
  await orders.saveEvidence(order.id, {
    requestId: event.request_id,
    visitorId: event.visitor_id,
    ipAddress: event.ip_address,
    countryCode: event.country_code,
    suspectLevel: event.suspect_level,
  })

  const cards = await payments.distinctCardsByVisitor(event.visitor_id, { hours: 24 })
  if (cards >= 4) return 'refuse'                                  // card testing

  if (event.suspect_level === 'high') return 'review'
  if (event.suspect_level !== 'low') return 'authenticate'         // medium, or not scored
  if (event.anonymity_network || event.country_code !== order.billingCountry) return 'authenticate'
  return 'capture'
}

A starting policy

SituationAction
4 or more different cards from one visitor in 24 hoursRefuse, and stop taking payments from that visitor for a day.
high levelHold the order for manual review before fulfilment.
medium, unscored, or no usable identificationAsk the payment provider to authenticate the cardholder, such as with 3-D Secure.
A Tor, VPN, proxy or hosting network, or a network country different from the billing countryAuthenticate the cardholder.
OtherwiseCapture.

Signals that matter here

GroupWhy it matters for payments
tor, datacenter_proxy, residential_proxy, vpnUsed to match the stolen card's country and hide the buyer.
location_spoofingThe device pretends to be near the cardholder.
bot, high_activityCard testing is scripted, and one device makes far more payments than a customer would.
browser_tampering, virtual_machine, device_farmTooling that disguises the device between attempts.

Keep evidence for chargebacks

Chargebacks arrive long after the 30 days Fingerly keeps events, so keep what you need at the time of the order: the request ID, the visitor ID, the address and country, and the level. To keep the complete events, subscribe to the identification.completed webhook or export them with List events. See data retention.

A visitor ID that placed earlier, undisputed orders for the same customer is useful evidence that the disputed order came from the customer too.

Roll it out

  • Observe first. Run the check and log the decision it would have made, next to what actually happened, for a week or two.
  • Tune. Look at the sessions the policy would have stopped in Identification > Events, and adjust risk weights and the thresholds in your own code until they match what you see.
  • Enforce gradually. Turn on the friction a real customer can pass before the outright blocks.