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
| Moment | Tag | Why |
|---|---|---|
| Submitting the payment | checkout:<order id> | The tag ties the identification to one order. |
| Adding or updating a saved card | add-card | Card testing often happens here rather than at checkout. |
| Buying gift cards or digital goods | checkout:<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
| Situation | Action |
|---|---|
| 4 or more different cards from one visitor in 24 hours | Refuse, and stop taking payments from that visitor for a day. |
high level | Hold the order for manual review before fulfilment. |
medium, unscored, or no usable identification | Ask 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 country | Authenticate the cardholder. |
| Otherwise | Capture. |
Signals that matter here
| Group | Why it matters for payments |
|---|---|
tor, datacenter_proxy, residential_proxy, vpn | Used to match the stolen card's country and hide the buyer. |
location_spoofing | The device pretends to be near the cardholder. |
bot, high_activity | Card testing is scripted, and one device makes far more payments than a customer would. |
browser_tampering, virtual_machine, device_farm | Tooling 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.