Skip to main content
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.

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

1

Run Tripwire normally at signup and/or claim

You need a session verdict for the fraud signal, and you need the durable visitor fingerprint.
2

Extract visitor_fingerprint.id from the verified token

This ID is stable across sessions on the same device.
3

Look up prior activity for that fingerprint

GET /v1/fingerprints/:visitorId returns lifecycle, session count, and recent verdict history.
4

Check your own record of prior claims

Cross-reference the visitor ID against your own promo_claims table.
5

Apply the claim policy

One claim per visitor fingerprint over a window you define — often 30–90 days.

Client integration

The browser-side integration is the same as any other sensitive action — the difference is entirely on the server.
<script type="module">
  const tripwirePromise = import("https://cdn.tripwirejs.com/t.js").then(
    (Tripwire) =>
      Tripwire.start({
        publishableKey: "pk_live_your_publishable_key",
      }),
  );

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

    return fetch("/api/promo/claim", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        code,
        tripwire: { sessionId, sealedToken },
      }),
    });
  }
</script>
waitForFingerprint() matters here. Promo abuse defense depends on getting a stable visitor_fingerprint.id, and that ID is only assigned once Tripwire 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

Node.js
const { Tripwire, safeVerifyTripwireToken } = require("@abxy/tripwire-server");
const client = new Tripwire({ secretKey: process.env.TRIPWIRE_SECRET_KEY });

app.post("/api/promo/claim", async (req, res) => {
  const result = safeVerifyTripwireToken(
    req.body.tripwire.sealedToken,
    process.env.TRIPWIRE_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,
    tripwireSessionId: req.body.tripwire.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 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.
  • Tripwire’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.
Node.js
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:
FieldWhat it tells you
lifecycle.first_seen_atFirst time Tripwire saw this device anywhere on your site.
lifecycle.last_seen_atMost recent session on this device.
lifecycle.seen_countTotal number of sessions Tripwire has frozen for this fingerprint.
lifecycle.expires_atWhen Tripwire will retire this fingerprint record.
latest_request.ip_addressMost 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.
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.

When the visitor ID is missing

visitor_fingerprint is null on sessions where Tripwire 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

Signup protection

Stop the account factory that feeds promo abuse.

Server verification

Reference for token verification and durable readback.

Fingerprints API

Full API shape for GET /v1/fingerprints/:visitorId.

Going to production

Rollout plan and monitoring.