[{"data":1,"prerenderedAt":161},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fuse-cases\u002Fpayment-fraud":3},{"page":4,"toc":151,"updated":160},{"path":5,"title":6,"seoTitle":7,"description":8,"blocks":9},"\u002Fdocs\u002Fuse-cases\u002Fpayment-fraud","Payment fraud","Reduce Payment Fraud, Card Testing and Chargebacks","Score every checkout, step up risky payments, hold high-risk orders for review, stop card testing per device, and keep evidence for chargeback disputes.",[10,13,18,36,39,54,57,59,68,70,73,79,82,102,105,122,125,127,129,132,138],{"type":11,"text":12},"p","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.",{"type":14,"level":15,"text":16,"id":17},"heading",2,"Where to identify","where-to-identify",{"type":19,"columns":20,"rows":24},"table",[21,22,23],"Moment","Tag","Why",[25,29,33],[26,27,28],"Submitting the payment","`checkout:\u003Corder id>`","The tag ties the identification to one order.",[30,31,32],"Adding or updating a saved card","`add-card`","Card testing often happens here rather than at checkout.",[34,27,35],"Buying gift cards or digital goods","Instantly resellable, so a favourite of stolen cards.",{"type":14,"level":15,"text":37,"id":38},"Identify in the client","identify-in-the-client",{"type":40,"samples":41},"code",[42,46,50],{"label":43,"lang":44,"code":45},"JavaScript","ts","import { load } from '@fingerly\u002Fweb-js'\n\nconst fingerly = await load({ apiKey: 'fly_pk_us_production_…' })\n\nasync function submit() {\n  let requestId: string | undefined\n  try {\n    ({ requestId } = await fingerly.identify({ tag: 'checkout:' + orderId }))\n  } catch {\n    \u002F\u002F Carry on: your server treats a missing request ID as missing evidence.\n  }\n  await fetch('\u002Fapi\u002Fcheckout', { method: 'POST', headers: { 'Content-Type': 'application\u002Fjson' }, body: JSON.stringify({ orderId, requestId }) })\n}",{"label":47,"lang":48,"code":49},"Swift","swift","let requestId = try? await fingerly.identify(tag: \"checkout:\\(orderId)\").requestId\ntry await api.pay(orderId: orderId, requestId: requestId)",{"label":51,"lang":52,"code":53},"Kotlin","kotlin","val requestId = runCatching { fingerly.identify(tag = \"checkout:$orderId\").requestId }.getOrNull()\napi.pay(orderId, requestId)",{"type":14,"level":15,"text":55,"id":56},"Read the event on your server","read-the-event-on-your-server",{"type":11,"text":58},"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.",{"type":40,"samples":60},[61,64],{"label":62,"lang":44,"code":63},"Node.js","\u002F\u002F fingerly.server.ts\nimport { load, FingerlyAPIError } from '@fingerly\u002Fnode'\n\nconst fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! })\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\nasync function readEvent(requestId: string) {\n  for (let attempt = 1; ; attempt++) {\n    try {\n      return await fingerly.events.get(requestId)\n    } catch (error) {\n      const status = error instanceof FingerlyAPIError ? error.status : undefined\n      if (status === 404 && attempt \u003C 4) await sleep(attempt * 250)   \u002F\u002F not readable yet\n      else if (status === 404 || status === 422) return null\n      else throw error\n    }\n  }\n}\n\n\u002F**\n * The stored event behind a request ID, or null when there is nothing to\n * trust: no ID, an unknown ID, another action's tag, too old, or used before.\n *\u002F\nexport async function verifiedEvent(requestId: unknown, tag: string, maxAgeMs = 2 * 60 * 1000) {\n  if (typeof requestId !== 'string' || requestId === '') return null\n\n  const event = await readEvent(requestId)\n  if (!event || event.tag !== tag) return null\n  if (Date.now() - Date.parse(event.occurred_at) > maxAgeMs) return null\n  if (!(await usedRequestIds.add(event.request_id))) return null   \u002F\u002F your store: true only the first time\n\n  return event\n}\n\n\u002F** Whether any of these signal groups fired. *\u002F\nexport const fired = (event: { triggers: Array\u003C{ signal: string }> }, ...groups: string[]) =>\n  event.triggers.some((trigger) => groups.includes(trigger.signal))",{"label":65,"lang":66,"code":67},"Python","python","# fingerly_server.py\nimport os\nimport time\nfrom datetime import datetime, timedelta, timezone\nfrom fingerly import Fingerly, FingerlyAPIError\n\nfingerly = Fingerly(secret_key=os.environ[\"FINGERLY_SECRET_KEY\"])\n\n\ndef _read_event(request_id):\n    for attempt in range(1, 5):\n        try:\n            return fingerly.events.get(request_id)\n        except FingerlyAPIError as error:\n            if error.status == 404 and attempt \u003C 4:\n                time.sleep(attempt * 0.25)  # not readable yet\n            elif error.status in (404, 422):\n                return None\n            else:\n                raise\n\n\ndef verified_event(request_id, tag, max_age=timedelta(minutes=2)):\n    \"\"\"The stored event behind a request ID, or None when there is nothing to trust.\"\"\"\n    if not isinstance(request_id, str) or not request_id:\n        return None\n\n    event = _read_event(request_id)\n    if event is None or event.tag != tag:\n        return None\n    if datetime.now(timezone.utc) - event.occurred_at > max_age:\n        return None\n    if not used_request_ids.add(event.request_id):  # your store: True only the first time\n        return None\n    return event\n\n\ndef fired(event, *groups):\n    \"\"\"Whether any of these signal groups fired.\"\"\"\n    return any(trigger.signal in groups for trigger in event.triggers)",{"type":11,"text":69},"`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.",{"type":14,"level":15,"text":71,"id":72},"Decide","decide",{"type":40,"samples":74},[75,77],{"label":62,"lang":44,"code":76},"import { verifiedEvent } from '.\u002Ffingerly.server'\n\nexport async function checkoutDecision(order: Order, requestId: unknown) {\n  const event = await verifiedEvent(requestId, 'checkout:' + order.id)\n  if (!event) return 'authenticate'   \u002F\u002F 3-D Secure or your equivalent\n\n  \u002F\u002F Evidence for a dispute, kept with the order for as long as you keep orders.\n  await orders.saveEvidence(order.id, {\n    requestId: event.request_id,\n    visitorId: event.visitor_id,\n    ipAddress: event.ip_address,\n    countryCode: event.country_code,\n    suspectLevel: event.suspect_level,\n  })\n\n  const cards = await payments.distinctCardsByVisitor(event.visitor_id, { hours: 24 })\n  if (cards >= 4) return 'refuse'                                  \u002F\u002F card testing\n\n  if (event.suspect_level === 'high') return 'review'\n  if (event.suspect_level !== 'low') return 'authenticate'         \u002F\u002F medium, or not scored\n  if (event.anonymity_network || event.country_code !== order.billingCountry) return 'authenticate'\n  return 'capture'\n}",{"label":65,"lang":66,"code":78},"from fingerly_server import verified_event\n\n\ndef checkout_decision(order, request_id):\n    event = verified_event(request_id, f\"checkout:{order.id}\")\n    if event is None:\n        return \"authenticate\"  # 3-D Secure or your equivalent\n\n    # Evidence for a dispute, kept with the order for as long as you keep orders.\n    orders.save_evidence(\n        order.id,\n        request_id=event.request_id,\n        visitor_id=event.visitor_id,\n        ip_address=event.ip_address,\n        country_code=event.country_code,\n        suspect_level=event.suspect_level,\n    )\n\n    cards = payments.distinct_cards_by_visitor(event.visitor_id, hours=24)\n    if cards >= 4:\n        return \"refuse\"  # card testing\n\n    if event.suspect_level == \"high\":\n        return \"review\"\n    if event.suspect_level != \"low\":\n        return \"authenticate\"  # medium, or not scored\n    if event.anonymity_network or event.country_code != order.billing_country:\n        return \"authenticate\"\n    return \"capture\"",{"type":14,"level":15,"text":80,"id":81},"A starting policy","a-starting-policy",{"type":19,"columns":83,"rows":86},[84,85],"Situation","Action",[87,90,93,96,99],[88,89],"4 or more different cards from one visitor in 24 hours","Refuse, and stop taking payments from that visitor for a day.",[91,92],"`high` level","Hold the order for manual review before fulfilment.",[94,95],"`medium`, unscored, or no usable identification","Ask the payment provider to authenticate the cardholder, such as with 3-D Secure.",[97,98],"A Tor, VPN, proxy or hosting network, or a network country different from the billing country","Authenticate the cardholder.",[100,101],"Otherwise","Capture.",{"type":14,"level":15,"text":103,"id":104},"Signals that matter here","signals-that-matter-here",{"type":19,"columns":106,"rows":109},[107,108],"Group","Why it matters for payments",[110,113,116,119],[111,112],"`tor`, `datacenter_proxy`, `residential_proxy`, `vpn`","Used to match the stolen card's country and hide the buyer.",[114,115],"`location_spoofing`","The device pretends to be near the cardholder.",[117,118],"`bot`, `high_activity`","Card testing is scripted, and one device makes far more payments than a customer would.",[120,121],"`browser_tampering`, `virtual_machine`, `device_farm`","Tooling that disguises the device between attempts.",{"type":14,"level":15,"text":123,"id":124},"Keep evidence for chargebacks","keep-evidence-for-chargebacks",{"type":11,"text":126},"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`](\u002Freference\u002Fwebhooks\u002Fidentification-completed) webhook or export them with [List events](\u002Freference\u002Flist-events). See [data retention](\u002Fdocs\u002Fdata-retention#keeping-your-own-copy).",{"type":11,"text":128},"A visitor ID that placed earlier, undisputed orders for the same customer is useful evidence that the disputed order came from the customer too.",{"type":14,"level":15,"text":130,"id":131},"Roll it out","roll-it-out",{"type":133,"items":134},"list",[135,136,137],"**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](\u002Fdocs\u002Frisk-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.",{"type":139,"columns":15,"cards":140},"cards",[141,146],{"title":142,"text":143,"href":144,"icon":145},"Account takeover","Protect saved cards behind the login.","\u002Fdocs\u002Fuse-cases\u002Faccount-takeover","lock",{"title":147,"text":148,"href":149,"icon":150},"Webhooks","Keep your own copy of every identification.","\u002Fdocs\u002Fwebhooks","webhook",[152,153,154,155,156,157,158,159],{"id":17,"text":16,"level":15},{"id":38,"text":37,"level":15},{"id":56,"text":55,"level":15},{"id":72,"text":71,"level":15},{"id":81,"text":80,"level":15},{"id":104,"text":103,"level":15},{"id":124,"text":123,"level":15},{"id":131,"text":130,"level":15},"2026-09-17T16:58:12.000Z",1789667797514]