[{"data":1,"prerenderedAt":160},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fuse-cases\u002Fpromotion-abuse":3},{"page":4,"toc":151,"updated":159},{"path":5,"title":6,"seoTitle":7,"description":8,"blocks":9},"\u002Fdocs\u002Fuse-cases\u002Fpromotion-abuse","Promotion abuse","Prevent Coupon, Referral and Free Trial Abuse","Enforce one redemption per device for coupons, referral rewards and free trials, however many accounts or email addresses the device uses.",[10,13,18,37,40,55,58,60,69,71,74,76,82,85,105,108,125,129,132,138],{"type":11,"text":12},"p","A promotion meant for one customer, redeemed by one person many times: a first-order discount on ten new accounts, a referral reward for referring yourself, a free trial started again every month. Limits per account or per email address do not help, because accounts and addresses are free. A limit per device does.",{"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],"Applying a coupon or promotion code","`promo:\u003Ccode>`","The tag binds the identification to one promotion.",[30,31,32],"Claiming a referral reward","`referral:\u003Creferrer id>`","Compare the device with the referrer's.",[34,35,36],"Starting a free trial","`trial-start`","One trial per device.",{"type":14,"level":15,"text":38,"id":39},"Identify in the client","identify-in-the-client",{"type":41,"samples":42},"code",[43,47,51],{"label":44,"lang":45,"code":46},"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: 'promo:' + code }))\n  } catch {\n    \u002F\u002F Carry on: your server treats a missing request ID as missing evidence.\n  }\n  await fetch('\u002Fapi\u002Fpromotions\u002Fredeem', { method: 'POST', headers: { 'Content-Type': 'application\u002Fjson' }, body: JSON.stringify({ code, requestId }) })\n}",{"label":48,"lang":49,"code":50},"Swift","swift","let requestId = try? await fingerly.identify(tag: \"promo:\\(code)\").requestId\ntry await api.redeem(code: code, requestId: requestId)",{"label":52,"lang":53,"code":54},"Kotlin","kotlin","val requestId = runCatching { fingerly.identify(tag = \"promo:$code\").requestId }.getOrNull()\napi.redeem(code, requestId)",{"type":14,"level":15,"text":56,"id":57},"Read the event on your server","read-the-event-on-your-server",{"type":11,"text":59},"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":41,"samples":61},[62,65],{"label":63,"lang":45,"code":64},"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":66,"lang":67,"code":68},"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":70},"`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":72,"id":73},"Decide","decide",{"type":11,"text":75},"Enforce the limit in your database with a unique index on the promotion and the visitor ID, so two redemptions racing each other cannot both succeed.",{"type":41,"samples":77},[78,80],{"label":63,"lang":45,"code":79},"import { fired, verifiedEvent } from '.\u002Ffingerly.server'\n\nexport async function redeem(accountId: string, code: string, requestId: unknown) {\n  const event = await verifiedEvent(requestId, 'promo:' + code)\n\n  \u002F\u002F A promotion is optional: without evidence, ask for more before applying it.\n  if (!event || fired(event, 'fingerprint_suppressed')) return 'verify'\n  if (event.suspect_level === 'high') return 'refuse'\n\n  \u002F\u002F unique index on (code, visitor_id)\n  const inserted = await redemptions.insertIfAbsent({ code, visitorId: event.visitor_id, accountId })\n  return inserted ? 'apply' : 'already-redeemed'\n}\n\nexport async function isSelfReferral(referrerId: string, requestId: unknown) {\n  const event = await verifiedEvent(requestId, 'referral:' + referrerId)\n  if (!event) return true   \u002F\u002F no evidence, no reward\n  return accounts.hasUsedDevice(referrerId, event.visitor_id)\n}",{"label":66,"lang":67,"code":81},"from fingerly_server import fired, verified_event\n\n\ndef redeem(account_id, code, request_id):\n    event = verified_event(request_id, f\"promo:{code}\")\n\n    # A promotion is optional: without evidence, ask for more before applying it.\n    if event is None or fired(event, \"fingerprint_suppressed\"):\n        return \"verify\"\n    if event.suspect_level == \"high\":\n        return \"refuse\"\n\n    # unique index on (code, visitor_id)\n    inserted = redemptions.insert_if_absent(code=code, visitor_id=event.visitor_id, account_id=account_id)\n    return \"apply\" if inserted else \"already-redeemed\"\n\n\ndef is_self_referral(referrer_id, request_id):\n    event = verified_event(request_id, f\"referral:{referrer_id}\")\n    if event is None:\n        return True  # no evidence, no reward\n    return accounts.has_used_device(referrer_id, event.visitor_id)",{"type":14,"level":15,"text":83,"id":84},"A starting policy","a-starting-policy",{"type":19,"columns":86,"rows":89},[87,88],"Situation","Action",[90,93,96,99,102],[91,92],"The device already redeemed this promotion","Refuse the discount, and say it has already been used on this device.",[94,95],"A referral where the new customer's device has been used by the referrer","Create the account, but pay no reward.",[97,98],"`high` level","Refuse the promotion.",[100,101],"`fingerprint_suppressed` fired, or no usable identification","Ask for verification, such as a phone number, before applying it.",[103,104],"Otherwise","Apply it.",{"type":14,"level":15,"text":106,"id":107},"Signals that matter here","signals-that-matter-here",{"type":19,"columns":109,"rows":112},[110,111],"Group","Why it matters for promotions",[113,116,119,122],[114,115],"`fingerprint_suppressed`","The device hides enough to get a new visitor ID every time, which would defeat a per-device limit.",[117,118],"`device_farm`, `android_emulator`, `ios_simulator`, `cloned_app`","Many \"devices\" that are really one person's setup.",[120,121],"`bot`","Redemptions scripted at scale.",[123,124],"`residential_proxy`, `vpn`","Used to make repeated sign-ups look like different households.",{"type":126,"tone":127,"text":128},"callout","tip","Tell customers the limit is per device in the promotion's terms. A real customer who is refused then knows why.",{"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},"Sign-up abuse","Stop the accounts before they redeem.","\u002Fdocs\u002Fuse-cases\u002Fsign-up-abuse","users",{"title":147,"text":148,"href":149,"icon":150},"Payment fraud","Protect the order the promotion applies to.","\u002Fdocs\u002Fuse-cases\u002Fpayment-fraud","card",[152,153,154,155,156,157,158],{"id":17,"text":16,"level":15},{"id":39,"text":38,"level":15},{"id":57,"text":56,"level":15},{"id":73,"text":72,"level":15},{"id":84,"text":83,"level":15},{"id":107,"text":106,"level":15},{"id":131,"text":130,"level":15},"2026-09-17T16:58:12.000Z",1789667797514]