Integration docs
FanVerify follows the reCAPTCHA v2 integration contract on purpose. If your checkout already has a CAPTCHA, swapping it in is a find-and-replace.
Quickstart
- Get a site key and secret. The demo pair below works against this deployment.
- Add the script and a container to your checkout or presale page.
- On form submit, post the
fanverify-responsefield to your server. - Your server calls
/api/v1/siteverifyand gates inventory on the result.
Site key (public): fv_pk_demo_6LcDemo
Secret (server-only): fv_sk_demo_change_meClient widget
Auto-render: any element with class fanverify and a data-sitekey becomes a badge. Clicking it opens the challenge in a modal iframe. On success the widget writes a hidden input named fanverify-response into the container and calls your callback.
<script src="https://YOUR-FANVERIFY-HOST/fanverify.js" async defer></script>
<form action="/checkout" method="POST">
<div class="fanverify"
data-sitekey="fv_pk_demo_6LcDemo"
data-context="taylor-swift"
data-theme="dark"
data-callback="onFanVerified"
data-expired-callback="onFanExpired"></div>
<button type="submit" id="buy" disabled>Buy tickets</button>
</form>
<script>
function onFanVerified(token) { document.getElementById("buy").disabled = false; }
function onFanExpired() { document.getElementById("buy").disabled = true; }
</script>Attributes
| data-sitekey | Required. Your public site key. |
| data-context | Knowledge pack id, e.g. oasis. Determines what the fan is quizzed on. |
| data-theme | light (default) or dark. Affects the badge only. |
| data-difficulty | easy | medium | hardcore. Demo-only: production sites pin one difficulty per event in site settings and the server ignores the client value. |
| data-label / data-sublabel | Badge copy. Defaults: “I'm a real fan” / “Verify to continue”. |
| data-callback | Global function name, called with the token. |
| data-expired-callback | Called when a token ages out (110 s) before you used it. |
| data-error-callback | Called when the fan fails the challenge. |
Server verification
Never trust the token in the browser. Exchange it server-to-server. Tokens are signed, expire two minutes after issue, and can be redeemed once.
curl -X POST https://YOUR-FANVERIFY-HOST/api/v1/siteverify \
-d secret=fv_sk_demo_change_me \
-d response=<fanverify-response>Response fields
{
"success": true,
"score": 1.0, // 0–1. fraction of rounds correct
"context": "oasis", // pack the fan was verified against
"difficulty": "medium", // preset the challenge was built with
"lane": "fast", // "fast" = passed; "slow" = failed, then waited out the slow lane
"hostname": "tickets.example.com", // page that embedded the widget
"challenge_ts": "2026-09-10T14:02:11.392Z",
"rounds": { "total": 3, "correct": 3 },
"flags": [] // e.g. ["r1:low_pointer_activity"]
}
// failure
{ "success": false, "error-codes": ["timeout-or-duplicate"] }| missing-input-secret / invalid-input-secret | Secret absent or unknown. |
| missing-input-response / invalid-input-response | Token absent, malformed, or signature failed. |
| site-key-mismatch | Token was issued for a different site key than this secret. |
| timeout-or-duplicate | Token expired (2 min) or already redeemed. |
| rate-limited | Too many calls from this IP. |
Recommended policy: score ≥ 0.67 to buy, and cap quantity lower when flags is non-empty. For high-demand on-sales, route score = 1.0 to the fast lane.
Contexts (knowledge packs)
Promoters and artists set the questions. A pack is owned by the artist, label or promoter. You can add any fact you like, remove anything you don't want asked, write questions for a single tour or on-sale, and choose the difficulty and round mix per event. The packs in this demo are just starting points.
A context is a JSON knowledge pack: albums with release dates and running orders, non-album tracks used as distractors, free-text facts with accepted spellings, and year facts. Six generators compose rounds from it. Adding an artist means adding a pack; no new code.
| id | name | albums | facts |
|---|---|---|---|
| oasis | 🎸 Oasis | 7 | 26 |
| taylor-swift | ✨ Taylor Swift | 12 | 25 |
| maisie-peters | 🪄 Maisie Peters | 5 | 24 |
| coldplay | 🪐 Coldplay | 10 | 32 |
// src/lib/fanverify/packs/<artist>.ts
export const artist: KnowledgePack = {
id: "artist", name: "Artist", tagline: "World Tour", glyph: "🎤", hue: 300,
yearRange: [1990, 2026],
albums: [{ id: "debut", title: "Debut", released: "1999-05-04", tracks: ["…"], artwork: "https://…/debut.jpg" /* optional */ }],
nonAlbumTracks: ["B-side", "Standalone single"],
facts: [{ id: "drummer", prompt: "Who drummed on Debut?", answers: ["Name", "Surname"], difficulty: 2 }],
yearFacts: [{ id: "formed", prompt: "Year formed?", year: 1995, difficulty: 2 }],
};Content policy: titles, dates, personnel, running orders and venue facts by default. No lyrics, no audio. Album artwork is optional and only for rights holders: set artwork on an album and the sort and match rounds show your real sleeves; leave it out and the widget draws a generated one. FanVerify never ships third-party covers itself.
Security model
- Stateless, sealed challenges. Solutions travel inside an AES-256-GCM sealed challenge token; progress travels in a sealed progress token. Both are opaque to the client and bound to each other.
- One-shot rounds, strict sequence. A round can be answered once, in order. Out-of-sequence or replayed answers are rejected.
- Server-side timing. Time limits are enforced from server timestamps, with a 5 s network grace. Implausibly fast answers are flagged for the vendor, never marked wrong: a fan who knows the answer instantly is not a bot.
- Behavioural signals. Drag rounds require a real drag gesture (pointer or keyboard). Low pointer activity, no typing and very fast answers are reported as flags in the siteverify response for the vendor's own policy. Counts only, never coordinates or content, and nothing is stored.
- Signed, single-use, short-lived result tokens. HMAC-SHA256, 2-minute TTL, redeemed once at siteverify.
- Rate limits on challenge creation, answers, and siteverify per IP.
- Slow lane. A fan who fails can wait (5 minutes by default, per site) and is then verified with
lane: "slow"and the threshold score. The wait is enforced server-side via a sealed ticket that cannot be redeemed early. Vendors can cap quantity or skip presale perks for slow-lane buyers.
What this does not claim: that a determined human with a search engine can never pass. That is by design. The goal is to make automation slow, flaky and expensive enough that bulk scalping stops paying, while a real fan finishes in under a minute.
Self-hosting
pnpm install
FANVERIFY_SECRET=$(openssl rand -hex 32) pnpm dev # http://localhost:3000
# production
vercel env add FANVERIFY_SECRET # long random string; rotating it invalidates in-flight tokens
vercel --prodSite keys live in src/lib/fanverify/sitekeys.ts for the demo; move them to a database for multi-tenant use. Rate limiting and single-use tracking are in-memory per instance; swap store.ts for Redis to make them global.