# 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.

Last updated: 2026-09-17

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

```ts JavaScript
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 }) })
}
```

```swift Swift
let requestId = try? await fingerly.identify(tag: "checkout:\(orderId)").requestId
try await api.pay(orderId: orderId, requestId: requestId)
```

```kotlin Kotlin
val requestId = runCatching { fingerly.identify(tag = "checkout:$orderId").requestId }.getOrNull()
api.pay(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.

```ts Node.js
// 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))
```

```python Python
# fingerly_server.py
import os
import time
from datetime import datetime, timedelta, timezone
from fingerly import Fingerly, FingerlyAPIError

fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])

def _read_event(request_id):
    for attempt in range(1, 5):
        try:
            return fingerly.events.get(request_id)
        except FingerlyAPIError as error:
            if error.status == 404 and attempt < 4:
                time.sleep(attempt * 0.25)  # not readable yet
            elif error.status in (404, 422):
                return None
            else:
                raise

def verified_event(request_id, tag, max_age=timedelta(minutes=2)):
    """The stored event behind a request ID, or None when there is nothing to trust."""
    if not isinstance(request_id, str) or not request_id:
        return None

    event = _read_event(request_id)
    if event is None or event.tag != tag:
        return None
    if datetime.now(timezone.utc) - event.occurred_at > max_age:
        return None
    if not used_request_ids.add(event.request_id):  # your store: True only the first time
        return None
    return event

def fired(event, *groups):
    """Whether any of these signal groups fired."""
    return any(trigger.signal in groups for trigger in event.triggers)
```

`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

```ts Node.js
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'
}
```

```python Python
from fingerly_server import verified_event

def checkout_decision(order, request_id):
    event = verified_event(request_id, f"checkout:{order.id}")
    if event is None:
        return "authenticate"  # 3-D Secure or your equivalent

    # Evidence for a dispute, kept with the order for as long as you keep orders.
    orders.save_evidence(
        order.id,
        request_id=event.request_id,
        visitor_id=event.visitor_id,
        ip_address=event.ip_address,
        country_code=event.country_code,
        suspect_level=event.suspect_level,
    )

    cards = payments.distinct_cards_by_visitor(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 or event.country_code != order.billing_country:
        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`](https://docs.fingerly.io/reference/webhooks/identification-completed) webhook or export them with [List events](https://docs.fingerly.io/reference/list-events). See [data retention](https://docs.fingerly.io/docs/data-retention#keeping-your-own-copy).

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](https://docs.fingerly.io/docs/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.

- [Account takeover](https://docs.fingerly.io/docs/use-cases/account-takeover): Protect saved cards behind the login.
- [Webhooks](https://docs.fingerly.io/docs/webhooks): Keep your own copy of every identification.
