> ## Documentation Index
> Fetch the complete documentation index at: https://usefoil.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Promo & trial abuse

> Stop promo abuse and free-trial farming. Use Foil's durable visitor fingerprint to catch repeat claims across account rotations, cookie clears, and incognito.

<Info>
  Promo abuse is the use case where a *single session verdict* is not the right question. An attacker creating a hundred accounts on one device to claim a hundred trial credits looks like a human on every individual session — because they are. The signal is cross-session: one visitor fingerprint claiming the offer too many times, too fast.
</Info>

## The threat

Promotional abuse covers a family of scams that share one property: the offer is valuable enough per account to justify creating many accounts. Common shapes:

* **Free-trial farming.** A new account gets 30 days of access, or \$X of credit, or a hardware sample. Attackers spin up accounts until the offer dries up and resell credits or access.
* **Referral bonus fraud.** "Refer a friend, get \$10." Attackers self-refer by creating both sides of the transaction.
* **Coupon / promo-code abuse.** A one-per-customer code is tested, shared, or stacked. The same device redeems it twenty times under twenty identities.
* **Signup-only bonuses.** Platforms that hand out tokens, NFTs, credits, or airdrop allocations at account creation.

Detection: catching any *one* fraudulent claim is hard, because the attacker has gone to some effort to make it look real. Catching the *second* and subsequent claims from the same device is straightforward, because the durable visitor fingerprint persists across account creation, cookie clears, and incognito sessions.

## The flow

<Steps>
  <Step title="Run Foil normally at signup and/or claim">
    You need a session verdict for the fraud signal, and you need the durable visitor fingerprint.
  </Step>

  <Step title="Extract visitor_fingerprint.id from the verified token">
    This ID is stable across sessions on the same device.
  </Step>

  <Step title="Look up prior activity for that fingerprint">
    `GET /v1/fingerprints/:visitorId` returns lifecycle, session count, and recent verdict history.
  </Step>

  <Step title="Check your own record of prior claims">
    Cross-reference the visitor ID against your own `promo_claims` table.
  </Step>

  <Step title="Apply the claim policy">
    One claim per visitor fingerprint over a window you define — often 30–90 days.
  </Step>
</Steps>

## Client integration

The browser-side integration is the same as any other sensitive action — the difference is entirely on the server.

```html theme={"dark"}
<script type="module">
  const foilPromise = import("https://cdn.usefoil.com/t.js").then(
    (Foil) =>
      Foil.start({
        publishableKey: "pk_live_your_publishable_key",
      }),
  );

  async function claimPromo(code) {
    const foil = await foilPromise;
    await foil.waitForFingerprint();
    const { sessionId, sealedToken } = await foil.getSession();

    return fetch("/api/promo/claim", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        code,
        foil: { sessionId, sealedToken },
      }),
    });
  }
</script>
```

`waitForFingerprint()` matters here. Promo abuse defense *depends* on getting a stable `visitor_fingerprint.id`, and that ID is only assigned once Foil has frozen the durable fingerprint server-side. Submitting before that can return a token without a visitor ID, which defeats the whole integration.

## Server verification

```javascript Node.js theme={"dark"}
const { Foil, safeVerifyFoilToken } = require("@abxy/foil-server");
const client = new Foil({ secretKey: process.env.FOIL_SECRET_KEY });

app.post("/api/promo/claim", async (req, res) => {
  const result = safeVerifyFoilToken(
    req.body.foil.sealedToken,
    process.env.FOIL_SECRET_KEY,
  );

  if (!result.ok || result.data.decision.verdict === "bot") {
    return res.status(403).json({ error: "Claim rejected" });
  }

  const visitorId = result.data.visitor_fingerprint?.id;
  if (!visitorId) {
    // Without a visitor ID we can't do cross-session deduplication.
    // Fall back to your standard one-per-account check.
    return claimWithAccountCheck(req, res);
  }

  if (await isLikelyRepeatClaim(visitorId, req.body.code, { windowHours: 24 * 30 })) {
    return res.status(409).json({ error: "Already claimed" });
  }

  // Record the claim keyed on visitor fingerprint, not just account ID.
  await recordClaim({
    visitorId,
    code: req.body.code,
    accountId: req.session.userId,
    foilSessionId: req.body.foil.sessionId,
  });

  await grantPromo(req.session.userId, req.body.code);
  res.json({ status: "claimed" });
});
```

The equivalent in other languages follows the same shape — see [Server verification](/server-verification) for the full language matrix.

## The cross-session check

`isLikelyRepeatClaim` is the piece that does the work. It combines two sources:

* **Your `promo_claims` table.** The ground truth for "this offer has already been given to this device." Query it first — it's cheap and decisive when positive.
* **Foil's durable fingerprint record.** Even if your own table says "no prior claim of this code," the visitor fingerprint's history can still flag the device as a serial claimer across *other* offers, or as part of a suspicious account constellation.

```javascript Node.js theme={"dark"}
async function isLikelyRepeatClaim(visitorId, code, { windowHours }) {
  // 1. Direct hit: this device has already claimed this code.
  const existing = await db.query(
    "SELECT 1 FROM promo_claims WHERE visitor_id = $1 AND code = $2 AND claimed_at > NOW() - INTERVAL '1 hour' * $3",
    [visitorId, code, windowHours],
  );
  if (existing.rows.length > 0) return true;

  // 2. Cross-signal: look up the visitor fingerprint's full history.
  const fingerprint = await client.fingerprints.get(visitorId);

  const firstSeen = new Date(fingerprint.lifecycle.first_seen_at);
  const seenCount = fingerprint.lifecycle.seen_count;
  const ageHours = (Date.now() - firstSeen.getTime()) / 36e5;

  // A fingerprint that just appeared in the last hour and has already
  // been seen 10+ times is probably cycling accounts on one device.
  if (ageHours < 1 && seenCount >= 10) return true;

  // Check recent session decisions — a device with a bot-verdict
  // in the last 24h should not be getting a promo.
  const recentBots = fingerprint.activity.sessions
    .filter((s) => Date.parse(s.decision.evaluated_at) > Date.now() - 86_400_000)
    .filter((s) => s.decision.verdict === "bot");
  if (recentBots.length > 0) return true;

  return false;
}
```

Key fields used here, from [`/v1/fingerprints/:visitorId`](/api-reference/fingerprints):

| Field                       | What it tells you                                                                                         |
| --------------------------- | --------------------------------------------------------------------------------------------------------- |
| `lifecycle.first_seen_at`   | First time Foil saw this device anywhere on your site.                                                    |
| `lifecycle.last_seen_at`    | Most recent session on this device.                                                                       |
| `lifecycle.seen_count`      | Total number of sessions Foil has frozen for this fingerprint.                                            |
| `lifecycle.expires_at`      | When Foil will retire this fingerprint record.                                                            |
| `latest_request.ip_address` | Most recent IP — useful for spotting residential proxy rotation.                                          |
| `activity.sessions[]`       | Recent sessions with their decisions. Look for `verdict: "bot"` or many `inconclusive` in a short window. |

## Scoring patterns

A single `isLikelyRepeatClaim → true` is a strong block signal. You can go further by composing a few facts into a score:

* **New fingerprint, many sessions in minutes.** Classic "spin up ten browsers, claim ten promos" pattern.
* **Seen many times, claim never before.** A long-tenured device that hasn't claimed this offer: probably fine.
* **Seen many times, multiple claims across different offers.** A device that keeps claiming offers under different accounts: very suspicious, even without a bot verdict.
* **`lifecycle.expires_at` in the past.** The fingerprint record has aged out — treat as a new device.

Some of these only make sense if you have enough traffic that the denominator isn't trivially small. Layer them on after the basic repeat-claim check is in place and generating useful signal.

<Warning>
  Don't deny promos purely on visitor-fingerprint match if you have a policy of "one per household." Real families share devices. The fingerprint signal is great evidence but shouldn't be the only input — combine with payment instrument, shipping address, or email domain as appropriate for your business.
</Warning>

## When the visitor ID is missing

`visitor_fingerprint` is `null` on sessions where Foil couldn't establish a durable ID — typically hardened privacy browsers (Firefox in `resistFingerprinting` mode, Brave with aggressive shields), very short sessions, or mobile webviews with storage disabled. Treat a missing visitor ID as "apply your normal one-per-account check, but don't grant a generous promo" rather than as a block signal in itself. Most missing-ID sessions are real privacy-conscious users; some are intentional evasion. Don't punish the first group to catch the second.

## What's next

<CardGroup cols={2}>
  <Card title="Signup protection" icon="user-plus" href="/use-cases/signup">
    Stop the account factory that feeds promo abuse.
  </Card>

  <Card title="Server verification" icon="shield-check" href="/server-verification">
    Reference for token verification and durable readback.
  </Card>

  <Card title="Fingerprints API" icon="fingerprint" href="/api-reference/fingerprints">
    Full API shape for `GET /v1/fingerprints/:visitorId`.
  </Card>

  <Card title="Going to production" icon="rocket" href="/going-to-production">
    Rollout plan and monitoring.
  </Card>
</CardGroup>
