Guides

Promotion abuse

Enforce one redemption per device for coupons, referral rewards and free trials, however many accounts or email addresses the device uses.

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.

Where to identify

MomentTagWhy
Applying a coupon or promotion codepromo:<code>The tag binds the identification to one promotion.
Claiming a referral rewardreferral:<referrer id>Compare the device with the referrer's.
Starting a free trialtrial-startOne trial per device.

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: 'promo:' + code }))
  } catch {
    // Carry on: your server treats a missing request ID as missing evidence.
  }
  await fetch('/api/promotions/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, 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

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.

import { fired, verifiedEvent } from './fingerly.server'

export async function redeem(accountId: string, code: string, requestId: unknown) {
  const event = await verifiedEvent(requestId, 'promo:' + code)

  // A promotion is optional: without evidence, ask for more before applying it.
  if (!event || fired(event, 'fingerprint_suppressed')) return 'verify'
  if (event.suspect_level === 'high') return 'refuse'

  // unique index on (code, visitor_id)
  const inserted = await redemptions.insertIfAbsent({ code, visitorId: event.visitor_id, accountId })
  return inserted ? 'apply' : 'already-redeemed'
}

export async function isSelfReferral(referrerId: string, requestId: unknown) {
  const event = await verifiedEvent(requestId, 'referral:' + referrerId)
  if (!event) return true   // no evidence, no reward
  return accounts.hasUsedDevice(referrerId, event.visitor_id)
}

A starting policy

SituationAction
The device already redeemed this promotionRefuse the discount, and say it has already been used on this device.
A referral where the new customer's device has been used by the referrerCreate the account, but pay no reward.
high levelRefuse the promotion.
fingerprint_suppressed fired, or no usable identificationAsk for verification, such as a phone number, before applying it.
OtherwiseApply it.

Signals that matter here

GroupWhy it matters for promotions
fingerprint_suppressedThe device hides enough to get a new visitor ID every time, which would defeat a per-device limit.
device_farm, android_emulator, ios_simulator, cloned_appMany "devices" that are really one person's setup.
botRedemptions scripted at scale.
residential_proxy, vpnUsed to make repeated sign-ups look like different households.

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.