[{"data":1,"prerenderedAt":212},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fprevent-promo-abuse":3},{"page":4,"toc":192,"updated":211},{"path":5,"title":6,"seoTitle":7,"description":8,"blocks":9},"\u002Fdocs\u002Fprevent-promo-abuse","Prevent promo abuse, referral fraud and free trial abuse","Prevent Promo Abuse, Referral Fraud and Free Trial Abuse","Stop coupon, referral and free trial abuse with one redemption per device: tag each redemption, check it on your server, and enforce it with a unique index.",[10,13,15,20,39,42,87,90,92,96,98,101,103,106,126,129,136,140,143,147,149,152,154,157,159,162,164,167,169],{"type":11,"text":12},"p","A promotion meant for one customer gets 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. **Promo abuse** survives limits per account or per email address, because accounts and addresses are free. A limit per device does not.",{"type":11,"text":14},"This guide enforces **one redemption per device** for promo codes, referral rewards and free trials with Fingerly, from the client to a database constraint.",{"type":16,"level":17,"text":18,"id":19},"heading",2,"Where to identify","where-to-identify",{"type":21,"columns":22,"rows":26},"table",[23,24,25],"Moment","Tag","Why",[27,31,35],[28,29,30],"Applying a coupon or promotion code","`promo:\u003Ccode>`","The tag binds the identification to one promotion.",[32,33,34],"Claiming a referral reward","`referral:\u003Creferrer id>`","Compare the new customer's device with the referrer's.",[36,37,38],"Starting a free trial","`trial-start`","One trial per device.",{"type":16,"level":17,"text":40,"id":41},"Enforce one redemption per device","enforce-one-redemption-per-device",{"type":43,"steps":44},"steps",[45,63,74],{"title":46,"blocks":47},"Identify when the visitor redeems",[48],{"type":49,"samples":50},"code",[51,55,59],{"label":52,"lang":53,"code":54},"JavaScript","ts","import { load } from '@fingerly\u002Fweb-js'\n\n\u002F\u002F consent: 'granted' only once you have consent (or another lawful basis)\nconst fingerly = await load({ apiKey: 'fly_pk_development_…', consent: 'granted' })\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":56,"lang":57,"code":58},"iOS","swift","let requestId = try? await fingerly.identify(tag: \"promo:\\(code)\").requestId\ntry await api.redeem(code: code, requestId: requestId)",{"label":60,"lang":61,"code":62},"Android","kotlin","val requestId = runCatching { fingerly.identify(tag = \"promo:$code\").requestId }.getOrNull()\napi.redeem(code, requestId)",{"title":64,"blocks":65},"Let the database enforce the limit",[66,68],{"type":11,"text":67},"Store the visitor ID with every redemption, and put a unique index on the promotion and the visitor ID. Two redemptions racing each other cannot both succeed, because the database refuses the second.",{"type":49,"samples":69},[70],{"label":71,"lang":72,"code":73},"SQL","text","CREATE TABLE redemptions (\n  code        text   NOT NULL,\n  visitor_id  text   NOT NULL,\n  account_id  bigint NOT NULL,\n  PRIMARY KEY (code, visitor_id)\n);",{"title":75,"blocks":76},"Read the event and apply the promotion",[77,79,85],{"type":11,"text":78},"The server code below uses two helpers from the [use-case recipes](\u002Fdocs\u002Fuse-cases): `verifiedEvent(requestId, tag)`, which reads the stored event with a secret key and returns it only when it belongs to the action, is recent and has not been used before, and `fired(event, ...groups)`, which says whether a [signal group](\u002Fdocs\u002Fsignals) fired.",{"type":49,"samples":80},[81],{"label":82,"lang":53,"code":83,"check":84},"Node.js","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  const inserted = await redemptions.insertIfAbsent({ code, visitorId: event.visitor_id, accountId })\n  return inserted ? 'apply' : 'already-redeemed'\n}",false,{"type":11,"text":86},"`redemptions.insertIfAbsent` is your own query: an insert that reports whether the row was new. Without a usable identification, the device cannot be counted, so ask for verification before applying the offer.",{"type":16,"level":17,"text":88,"id":89},"Stop referral fraud","stop-referral-fraud",{"type":11,"text":91},"A referral reward is abused when the referrer and the new customer are the same person. Identify when the reward is claimed, with the referrer in the tag, and compare the claiming device with the devices the referrer has used.",{"type":49,"samples":93},[94],{"label":82,"lang":53,"code":95,"check":84},"export 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}",{"type":11,"text":97},"Create the account either way, and pay no reward when it returns `true`. For the same reason, store the visitor ID with every account at sign-up, as in [detect multiple accounts](\u002Fdocs\u002Fdetect-multiple-accounts).",{"type":16,"level":17,"text":99,"id":100},"Stop free trial abuse","stop-free-trial-abuse",{"type":11,"text":102},"Use the same tag and table with a trial instead of a code: identify with `trial-start`, store the visitor ID with the trial, and allow one trial per device. A device that starts a second trial is told it has been used, or is offered the paid plan.",{"type":16,"level":17,"text":104,"id":105},"A starting policy","a-starting-policy",{"type":21,"columns":107,"rows":110},[108,109],"Situation","Action",[111,114,117,120,123],[112,113],"The device already redeemed this promotion","Refuse the discount, and say it has already been used on this device.",[115,116],"A referral where the new customer's device has been used by the referrer","Create the account, but pay no reward.",[118,119],"`high` level","Refuse the promotion.",[121,122],"`fingerprint_suppressed` fired, or no usable identification","Ask for verification, such as a phone number, before applying it.",[124,125],"Otherwise","Apply it.",{"type":16,"level":17,"text":127,"id":128},"Signals that matter for promotions","signals-that-matter-for-promotions",{"type":130,"items":131},"list",[132,133,134,135],"`fingerprint_suppressed`: the device hides enough to get a new visitor ID every time, which would defeat a per-device limit.","`device_farm`, `android_emulator`, `ios_simulator` and `cloned_app`: many \"devices\" that are really one person's setup.","`bot`: redemptions scripted at scale.","`residential_proxy` and `vpn`: used to make repeated sign-ups look like different households.",{"type":137,"tone":138,"text":139},"callout","tip","Tell customers the limit is per device in the promotion's terms. A real customer who is refused then knows why.",{"type":16,"level":17,"text":141,"id":142},"Common questions","common-questions",{"type":16,"level":144,"text":145,"id":146},3,"How do I prevent promo code abuse?","how-do-i-prevent-promo-code-abuse",{"type":11,"text":148},"Identify when the code is applied, read the stored event on your server, and store the visitor ID with the redemption under a unique index on the code and the visitor ID. A second redemption from the same device then fails, whatever account or email it uses.",{"type":16,"level":144,"text":150,"id":151},"Can people get around a per-device limit?","can-people-get-around-a-per-device-limit",{"type":11,"text":153},"A determined user can use another device, so the limit raises the cost of abuse rather than ending it. Combine it with the level and the signals above, and with your own checks such as a verified phone number.",{"type":16,"level":144,"text":155,"id":156},"What about customers on a shared device?","what-about-customers-on-a-shared-device",{"type":11,"text":158},"They share a visitor ID. If the promotion should allow a household two redemptions, count redemptions per visitor and set the limit above one, or ask for verification when a second arrives.",{"type":16,"level":144,"text":160,"id":161},"Does it work for in-app promotions?","does-it-work-for-in-app-promotions",{"type":11,"text":163},"Yes. Use the iOS, Android, React Native or Flutter SDK with the same tag, and enforce the same index on your server. A reinstalled Android app gets a new visitor ID, so pair the limit with verification for high-value offers.",{"type":16,"level":144,"text":165,"id":166},"How do I handle a visitor that cannot be identified?","how-do-i-handle-a-visitor-that-cannot-be-identified",{"type":11,"text":168},"Treat it as missing evidence. The answer has `identifiable: false` and the `fingerprint_suppressed` signal fires, so ask for verification before applying the offer rather than refusing.",{"type":170,"columns":17,"cards":171},"cards",[172,177,182,187],{"title":173,"text":174,"href":175,"icon":176},"Promotion abuse recipe","The full server helper and policy.","\u002Fdocs\u002Fuse-cases\u002Fpromotion-abuse","gift",{"title":178,"text":179,"href":180,"icon":181},"Detect multiple accounts","Count accounts per device.","\u002Fdocs\u002Fdetect-multiple-accounts","users",{"title":183,"text":184,"href":185,"icon":186},"Server-side verification","The four checks before you trust a result.","\u002Fdocs\u002Fserver-side-verification","shield",{"title":188,"text":189,"href":190,"icon":191},"Billing and usage","What an identification costs.","\u002Fdocs\u002Fbilling","card",[193,194,195,197,199,201,202,203,204,205,206,207,208,209,210],{"id":19,"text":18,"level":17},{"id":41,"text":40,"level":17},{"id":196,"text":46,"level":144},"step-identify-when-the-visitor-redeems",{"id":198,"text":64,"level":144},"step-let-the-database-enforce-the-limit",{"id":200,"text":75,"level":144},"step-read-the-event-and-apply-the-promotion",{"id":89,"text":88,"level":17},{"id":100,"text":99,"level":17},{"id":105,"text":104,"level":17},{"id":128,"text":127,"level":17},{"id":142,"text":141,"level":17},{"id":146,"text":145,"level":144},{"id":151,"text":150,"level":144},{"id":156,"text":155,"level":144},{"id":161,"text":160,"level":144},{"id":166,"text":165,"level":144},"2026-11-12T11:59:51.000Z",1794493376926]