Developers: integrate FingerPrint Checker in web, iOS and Android apps

Add device fingerprinting and free-trial abuse checks to a web app, an iOS app or an Android app: get keys, identify the device, and ask the API for a decision from your backend.

How it works

  1. Your backend creates an intent with your secret key (POST /v2/intents) and gives the intent token to the app. The intent binds the check to your project, one purpose and, optionally, one of your users.
  2. The app identifies the device with the intent token and your publishable key: web apps through the agent script, iOS and Android apps through the SDK. It gets back one opaque attestation. There is no seal key and no visitor ID or score on the client.
  3. The app sends the attestation to your backend, unchanged, together with the sign-up or claim.
  4. Your backend sends it to /v1/eligibility with your secret key and gets one answer: allow, turnstile, pow or deny. The visitor ID and trust score come back only there.
  5. Act on the answer on the server and return only the decision to the client. Never trust a visitor ID or score that the client reports itself.

1. Create a project and get your keys

Sign up (2,000 free checks, no card) and create a project in the console. A project has three keys:

  • Publishable key (pk_…): shipped inside your web page or app. With an intent token it only allows identify.
  • Secret key (sk_…): server only, shown once when you create or rotate it. It creates intents and calls every decision endpoint. Never ship it to a client.
  • Seal key: only for the legacy v1 flow (see the end of this page). Protocol v2 does not use it.
  • Allowed origins: the web origins that may call identify with this project, e.g. https://app.example.com.
  • Human check: a device’s first claim always needs one. Proof-of-work is the default and needs no setup. Cloudflare Turnstile is optional: create your own Turnstile widget, save its secret key in the project settings and choose Turnstile (or either: Turnstile on the web, proof-of-work in apps).

Create a free account

2. Create an intent on your backend

Before each identify, your backend calls POST /v2/intents with the secret key and passes the intentToken to the app. It is valid for 10 minutes. Pass the end user’s IP address as clientIp.

purpose says what the attestation may be spent on: claim:trial (or claim:<kind>) for /v1/eligibility, quota:<name> for /v1/quota, subjects for /v1/subjects/*. The decision call must match it, or it is denied with attestation_purpose.

subject is optional: your own user or session id, taken from your session and never from the request body. If you set it, the decision call must send the same subject, or it is denied with attestation_subject.

// Server side only. Keep the secret key in an environment variable.
const res = await fetch('https://api.fpchecker.com/v2/intents', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'X-Secret-Key': process.env.FPCHECKER_SECRET_KEY,
  },
  body: JSON.stringify({ purpose: 'claim:trial', subject: session.userId, clientIp }),
});
const { ok, intentToken } = await res.json(); // { ok, intentToken, expiresIn: 600 }
// give intentToken to the app; it is opaque, never parse it

3. Identify the device

Web app

A small script reads the browser signals and returns an opaque attestation for your backend.

Serve the agent from your own domain

Download https://fpchecker.com/static/fp-agent.min.js (version 0.8.0 or later for identifyV2; 0.9.0 or later for linkedId, tags and eventId) and serve it from your site. Content blockers block third-party fingerprinting scripts, and the first-party ID works only on your own origin.

<script src="/static/fp-agent.min.js"></script>

Identify the browser before sign-up or claim

Fetch an intent token from your backend, then call identifyV2 when the user submits the form and send the attestation with it. identifyV2 throws an IdentifyV2Error (with reason and status) if the request fails; let the backend decide what a missing attestation means. After a pow answer, call it again with powDifficulty set to the returned value.

const { intentToken } = await (await fetch('/api/fpchecker/intent', { method: 'POST' })).json();
const { attestation } = await window.FpGuardAgent.identifyV2({
  baseUrl: 'https://api.fpchecker.com',
  publishableKey: 'pk_...',
  intentToken,
  // powDifficulty: 18, // only when a decision answered action "pow"
});
return attestation; // opaque: send it to your backend unchanged

iOS and macOS app

Requires the FingerPrint Checker SDK: a Swift package for iOS 14+ and macOS 11+, with App Attest and DeviceCheck proofs that the request comes from your genuine app.

Add the Swift package

In Xcode, add the package URL below, or add it to Package.swift. The product is FingerPrintChecker.

.package(url: "https://github.com/AndrewYukon/FingerPrintChecker", from: "0.3.0")

Identify the device

identify(intentToken:) returns a result instead of throwing. sealKey is optional and only used by the legacy v1 identify().

import FingerPrintChecker

let checker = FingerPrintChecker(config: FPConfig(
    publishableKey: "pk_...",   // from your FingerPrint Checker project
    powDifficulty: nil          // optional: 8...28 when a decision asked for proof-of-work
))

let intentToken = try await fetchIntentFromBackend()   // your backend calls POST /v2/intents

switch await checker.identify(intentToken: intentToken) {
case .success(let s): try await sendToBackend(s.attestation)   // the backend decides
case .failure(let reason, let httpStatus): print("identify failed: \(reason) \(httpStatus)")
}

Turn on the Apple proofs

App Attest and DeviceCheck are on by default in the SDK. The server verifies them once your project knows your app:

  • App Attest: add the App Attest capability to the app, then enter your Team ID, bundle IDs and environment (development or production) in the project’s native settings.
  • DeviceCheck: create a DeviceCheck key in your Apple developer account and upload its key ID and private key in the same place.
  • Without them the device is still identified, but scored as unattested.

Android app

Requires the FingerPrint Checker SDK: a Kotlin library on Maven Central for Android 6.0+ (minSdk 23), with an optional Play Integrity proof.

Add the dependency

The library is on Maven Central.

implementation("com.fpchecker:fpchecker-android:0.3.0")

Identify the device

identify(intentToken) is a suspend function returning FpIdentifyResult; Java callers can use the overload that takes an FpIdentifyCallback. sealKey is optional and only used by the legacy v1 identify().

val checker = FpChecker(
    context,
    FpCheckerConfig(
        baseUrl = "https://api.fpchecker.com",
        publishableKey = "pk_...",          // from your FingerPrint Checker project
        cloudProjectNumber = 123456789012L, // optional: enables the Play Integrity proof
        powDifficulty = null,               // optional: 8..28 when a decision asked for proof-of-work
    ),
)

lifecycleScope.launch {
    val intentToken = fetchIntentFromBackend() // your backend calls POST /v2/intents
    when (val r = checker.identify(intentToken)) {
        is FpIdentifyResult.Success -> sendToBackend(r.attestation)   // the backend decides
        is FpIdentifyResult.Failure -> log("identify failed: ${r.reason} ${r.httpStatus}")
    }
}

Turn on Play Integrity

Set cloudProjectNumber to the Google Cloud project linked to your app in Play Console, then enter in the project’s native settings:

  • Your package name and the SHA-256 of your signing certificates (the upload key and Play app signing key).
  • A Google service account JSON that may decode Play Integrity tokens for that Cloud project.
  • Without them the device is still identified, but scored as unattested.

4. Ask for a decision from your backend

Send the attestation, the normalised email and the user’s IP address to /v1/eligibility with your secret key. Pass the attestation exactly as the client sent it. claim: true records the claim when the answer is allow, so the same device or email cannot claim again. If the intent had a subject, send the same subject.

visitorId and trustScore come back only here. Return just the decision (eligible, action, reason, powDifficulty) to the client.

const response = await fetch('https://api.fpchecker.com/v1/eligibility', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'X-Secret-Key': process.env.FPCHECKER_SECRET_KEY,
  },
  body: JSON.stringify({ attestation, email, claim: true, kind: 'trial', subject: session.userId, clientIp }),
});
const d = await response.json();
// Grant on your side when d.eligible is true. Answer the client with the decision only.
return { eligible: d.eligible === true, action: d.action, reason: d.reason, powDifficulty: d.powDifficulty };
actionWhat to do
allowGrant the trial, credit or coupon.
powThe default human check. Identify again with the returned powDifficulty (the web agent and the iOS and Android SDKs solve the proof-of-work in the background, about 1–2 seconds), then resend the same request with the new attestation. Reuse the intent token while it is valid, or ask your backend for a new one.
turnstileOnly if you chose Turnstile in the console. Show your own Cloudflare Turnstile widget (your site key), identify again, and resend with turnstileToken.
denyRefuse. reason says why, e.g. device_already_claimed, network_limit, linked_device_limit, disposable_email or low_trust.

One household, one claim

Every cap counts more than one browser. A second browser first seen on the same computer and network gets the same visitor ID, and the network cap (on by default: one claim per kind per network per 365 days) also covers the networks each device was seen on in the last 30 days, so a phone that was on the home Wi-Fi is still refused on mobile data. Denials for these say network_limit or linked_device_limit.

If your website and your iOS and Android apps are separate projects, put them in one claim group (console, Projects, Claim groups). Every cap of a project then counts the claims of all projects in the group, so a household that claimed on the website is refused in the app. Each project keeps its own limits; use the same kind names in every project. A project is in one group at most, and only projects of one account can share a group.

A phone that claims on mobile data before it was ever seen on the household network cannot be tied to it at that moment. When it later shows up on that network, its claim is flagged. List the flags from your backend with GET /v1/claims/flags and decide yourself; nothing is revoked automatically.

// Poll for claims found later to share a household (secret key; 60 requests a minute).
let after = lastSeenFlagId; // store the last flagId you processed
for (;;) {
  const url = `https://api.fpchecker.com/v1/claims/flags?limit=100${after ? `&after=${after}` : ''}`;
  const r = await (await fetch(url, { headers: { 'X-Secret-Key': process.env.FPCHECKER_SECRET_KEY } })).json();
  if (!r.ok) throw new Error(r.reason); // fails closed: never read an error as "nothing flagged"
  for (const f of r.flags) await reviewClaim(f.claimId, f.relatedClaimId, f.kind, f.reason); // your code
  if (!r.next) break;
  after = r.next;
}

Require v2 for the whole project

A project accepts v1 and v2 attestations until you turn on Require intent in the project settings (policy requireIntent). With it on, an attestation that was not made with an intent is denied with intent_required. Turn it on once every web page and app of the project uses v2.

Protocol details, error reasons and test vectors: https://github.com/AndrewYukon/FingerPrintChecker/blob/main/PROTOCOL-V2.md

Legacy: protocol v1 (seal key)

v1 still works: the client holds the project’s seal key and calls identify() without an intent, and the attestation it returns is readable by the client. The seal key ships in every page and app, so it proves little; new integrations should use v2.

Tag an identify with your own ids

An identify request may carry two optional fields. linkedId is your own user, order or session id (1 to 256 characters, no control characters). tags is a JSON object of your own labels (up to 4 KiB serialised, at most 3 levels deep). Invalid values are rejected with invalid_linked_id or invalid_tags before anything is stored.

Every successful identify now returns an eventId. You can set or change linkedId, tags and suspect on that event later with PATCH /v1/events/{eventId}, and then search by linkedId.

// Server side, secret key. Only linkedId, tags and suspect can be changed.
if (!/^ev_[0-9A-HJKMNP-TV-Z]{20}$/.test(eventId)) throw new Error('bad eventId'); // fail closed
await fetch(`https://api.fpchecker.com/v1/events/${eventId}`, {
  method: 'PATCH',
  headers: { 'content-type': 'application/json', 'X-Secret-Key': process.env.FPCHECKER_SECRET_KEY },
  body: JSON.stringify({ linkedId: 'user_123', tags: { page: 'checkout' }, suspect: false }),
});

Events API

Each identify is stored as an event for the retention period you choose in the console (7 to 90 days). Read events with your secret key; the calls are free and rate limited. An id that does not exist or belongs to another project answers 404.

GET /v1/events filters: visitorId, linkedId, ip (one address, or a range no wider than /16 for IPv4 or /48 for IPv6), suspect, bot, vpn, proxy, tor, incognito, tampering, virtualMachine, rooted, hooked, emulator, mitm, locationSpoofed and clonedApp (each true or false; see Signals below), platform (web, android, ios, macos or cli), start and end (ISO times; the default is the last 7 days and the span cannot exceed your retention), limit (1 to 100, default 20) and cursor. The answer is { events, next }; pass next as cursor for the following page.

GET /v1/visitors/{visitorId} returns first and last seen, the number of events, up to 20 recent linkedIds and up to 20 recent events. DELETE /v1/visitors/{visitorId} erases this project’s events and device records for the visitor (for privacy requests); it is idempotent and does not touch other projects.

Events are also listed in the console, where you can open one, see its raw JSON and export up to 1,000 rows as CSV.

// Every event, newest first. Fails closed: an error is never read as "no events".
let cursor;
do {
  const url = new URL('https://api.fpchecker.com/v1/events');
  url.searchParams.set('linkedId', 'user_123');
  url.searchParams.set('limit', '100');
  if (cursor) url.searchParams.set('cursor', cursor);
  const r = await (await fetch(url, { headers: { 'X-Secret-Key': process.env.FPCHECKER_SECRET_KEY } })).json();
  if (!r.events) throw new Error(r.error ?? r.reason);
  for (const event of r.events) handle(event); // your code
  cursor = r.next;
} while (cursor);

// One event: {
//   v: 1, eventId, projectId, visitorId, createdAt, platform, protocol, isNew,
//   identityConfidence, trustScore, linkedId, tags, suspect, ip,
//   ipInfo: { asn, country, timezone }, origin, agentVersion,
//   signals: { version: 2, ... } | null   (see Signals; older events read as version 1)
// }

Signals

Every event carries a signals object (version 2 on events stored since the bot directory release, version 1 on older ones; the only difference is the bot field) with what the server learned about the visit. It is on the events API, in the console and in webhooks, and never in the answer the browser or app receives. The signals are evidence to weigh, not verdicts, and they do not change the decision or trustScore.

Unknown is null, not false. false means the check ran and found nothing; null means it could not run (no data for that IP, a field the platform does not have, a source that is down). vpn.result: null is not “no VPN”. An event stored before signals existed has signals: null, and a webhook queued before the release may omit the key; treat missing and null alike.

Fields: ip (country, city, timezone, asn, asName, datacenter, tor, relay, blocklist); vpn (result and methods: timezone_mismatch, ip_range, relay, os_vpn; os_vpn comes from the mobile SDKs and means a device-level VPN, and consumers should ignore methods they do not know; timezone_mismatch also fires for a traveller whose device clock differs from the IP’s zone, and relay alone does not make result true); proxy (result, type datacenter); bot (result good, bad or not_detected, types webdriver, headless, playwright, selenium, inhuman_timing, inconsistent_environment, and on version 2 an info object { id, name, provider, category, identity } that is null when the visit matched no directory entry; identity is verified, signed, unknown or spoofed, see Bot check below; version 1 events never have good or info, and consumers should ignore values they do not know); tampering (result, antiDetectBrowser); privacyBrowser (result, name brave, firefox_rfp or tor_browser; not a risk by itself); virtualMachine; velocity; and mobile (see Mobile signals below; Android, iOS and macOS only, null for web).

velocity counts distinct IPs, countries and linkedIds, and events, for this visitor in the last 5 minutes, 1 hour and 24 hours including this event. It is approximate, and the whole object is null when the database was slow or busy. ip.blocklist.result is null when the list is unavailable or out of date, or the IP is IPv6 and the list has no IPv6 entries.

incognito reports private browsing in Chrome and Firefox on desktop: true or false there, null in every other browser and in native apps, and null whenever it cannot be told. It is not part of the trust score. devtools is reserved and always null: no release reports open developer tools. On native events bot and privacyBrowser keep their shape with result null, and virtualMachine is null. On native events tampering.result reflects device tampering (rooted or jailbroken, or hooked), and antiDetectBrowser stays null.

Search filters on GET /v1/events, each true or false: bot, vpn, proxy, tor, incognito, tampering, virtualMachine, rooted, hooked, emulator, mitm, locationSpoofed and clonedApp. The console form offers all of them. incognito=true and incognito=false match Chrome and Firefox desktop events only; every other event has null. bot=true matches bot.result bad (good does not count) and bot=false matches not_detected; the version 2 filters bot_result (good, bad or not_detected), bot_category, bot_identity (verified, signed, unknown or spoofed) and bot_id (a directory id) match only version 2 events; tor matches ip.tor; an unknown (null) value matches neither true nor false. Any other value is 400 invalid_request.

IP data attribution: wherever your product shows asName, show “IP address data powered by IPinfo” next to it.

// event.signals (null for events stored before signals existed)
{
  version: 2,
  ip: {
    country: 'DE', city: 'Berlin', timezone: 'Europe/Berlin', asn: 64500, asName: 'Example Hosting GmbH',
    datacenter: true, tor: false, relay: false, blocklist: { result: false, sources: [] },
  },
  vpn: { result: true, methods: ['timezone_mismatch'] },
  proxy: { result: true, type: 'datacenter' },
  bot: { result: 'bad', types: ['headless'], info: null },
  incognito: false,
  tampering: { result: false, antiDetectBrowser: false },
  privacyBrowser: { result: false, name: null },
  virtualMachine: false,
  devtools: null,
  velocity: {
    distinctIp: { '5m': 1, '1h': 2, '24h': 3 },
    distinctCountry: { '5m': 1, '1h': 1, '24h': 2 },
    distinctLinkedId: { '5m': 1, '1h': 1, '24h': 1 },
    events: { '5m': 2, '1h': 4, '24h': 9 },
  },
  mobile: null,
}

Mobile signals

Events from the Android, iOS and macOS SDKs (version 0.7.0 and later) carry device checks in signals.mobile, next to emulator, attested and integrity. They are read with your secret key like the other signals and never reach the app. They are reported by the app on the device, so treat them as evidence: a determined attacker can hide from them, and a clean result is not proof of a clean device.

rooted is true for a rooted Android device or a jailbroken iOS device. hooked means an instrumentation or hooking framework is attached to the app. debugger means a debugger is attached. developerMode is Android only. mitm means the device’s network traffic may be intercepted. vpn means a VPN is active on the device; vpn.result is then true and vpn.methods includes os_vpn. locationSpoofed means the device location is being faked by software. clonedApp is Android only and means the app runs in a cloned or parallel space. emulator is true for an emulator or simulator.

Each field is true (found), false (checked, nothing found) or null (not checked). null covers a check that could not run, for example when the app does not have the permission it needs, a platform that does not offer the check, an SDK older than 0.7.0, and an SDK that has device checks turned off. null is never “no”, and a rule condition on a null field is false.

You can search by rooted, hooked, emulator, mitm, locationSpoofed and clonedApp, and write rules on rooted, hooked, emulator, debugger, developerMode, mitm, locationSpoofed and clonedApp. The rule templates include a human check for rooted or jailbroken devices and a deny for spoofed locations. The signals do not change the decision or trustScore for now: they are shown for your own rules.

// event.signals.mobile on an Android event (null = not checked)
{
  emulator: false, attested: true, integrity: 'strong',
  rooted: true, hooked: false, debugger: false, developerMode: true,
  mitm: false, vpn: false, locationSpoofed: null, clonedApp: false,
}

Request filtering

A project can refuse unwanted traffic before anything is billed or recorded. The filters run on the four routes a browser or app talks to (challenge and identify, v1 and v2), and you set them per project in the console. A change takes effect within a minute.

Allowed origins: when the list is not empty, a request that carries an Origin header outside the list is refused. A request with no Origin header passes, as native apps and server callers send none. IP deny list: addresses and CIDR ranges, IPv4 and IPv6, up to 100 entries. Header rules: up to 20 rules, any one match refuses; each is a header name with equals, contains or exists and a value. Block crawlers: off by default; refuses requests from search engine and AI crawlers that announce themselves.

A refused request gets HTTP 403 with { "ok": false, "error": "request_filtered" }. The body never says which filter matched. It is not billed, writes no event and does not count against your write budget. Treat it like project_disabled: do not retry, and do not show it as a failed check of the visitor. A page that suddenly gets it from its own origin usually has that origin missing from the allowed list.

The console shows, for the last 7 days, how many requests each filter refused.

const r = await fetch('https://api.fpchecker.com/v2/identify', { method: 'POST', /* ... */ });
if (r.status === 403) {
  const body = await r.json();
  if (body.error === 'request_filtered') {
    // Refused by your own filters. Do not retry.
  }
}

Bot check

POST /v1/bot-check classifies a plain HTTP request that reached your backend, without the browser script. Send the method, the full URL, the client IP and the request headers with your secret key (sk_…). We never fetch the URL. The call is free, writes no event and is limited to 1,200 a minute per project and 3,000 a minute per calling IP (your server’s address); over either limit it answers 429.

Limits: the body is at most 32 KiB, with at most 64 headers of at most 4,096 characters each, and the url is at most 2,048 characters. Header names are lower-cased.

Answer: bot.result is good, bad or not_detected. bot.info names the directory entry (id, name, provider, category, identity) and is null when the request matched none. identity is verified, signed, unknown or spoofed; good needs verified or signed. Without the browser script the only bot.types are automation_tool (an HTTP library rather than a browser), headless and missing_headers (a browser that lacks headers every real browser sends). ip and proxy carry the network facts known for the address, and vpn carries only the network-based result and methods. There are no tampering, incognito or device fields.

Forward only these headers: user-agent, accept, accept-language, accept-encoding, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, sec-fetch-site, sec-fetch-mode, sec-fetch-dest, sec-fetch-user, signature, signature-input, signature-agent, upgrade-insecure-requests. Also forward every header named in the request’s signature-input (its covered components that do not start with @), because a signature fails when a covered header is missing. Still never forward cookie or authorization. Send the client IP your platform has verified, not one the client can choose.

Signed agents: some agents sign their requests with Web Bot Auth, a public standard for signed bot requests. When the operator is listed in the bot directory as Signed, forward signature, signature-input and signature-agent unchanged and send the url exactly as the agent requested it; a valid signature is confirmed as a good bot, and a failed one as spoofed. A request signed by an operator that is not listed is judged by its other headers, as if unsigned. Call bot-check once per request and reuse the answer. Never retry a signed request: a replayed signature reads as spoofed.

Events carry the same result in signals.bot, and the events API searches it with bot_result (good, bad or not_detected), bot_category, bot_identity (verified, signed, unknown or spoofed) and bot_id (a directory id). bot=true still means a bad bot only.

// POST https://api.fpchecker.com/v1/bot-check   X-Secret-Key: sk_...
// request
{
  "method": "GET",
  "url": "https://shop.example.com/checkout?x=1",
  "ip": "203.0.113.9",
  "headers": { "user-agent": "curl/8.5.0", "accept": "*/*" }
}
// response
{
  "bot": { "result": "bad", "types": ["automation_tool"], "info": null },
  "ip": { "country": "US", "asn": 16509, "asName": "Amazon.com, Inc.", "datacenter": true, "tor": false, "relay": false, "blocklist": { "result": false, "sources": [] } },
  "vpn": { "result": null, "methods": [] },
  "proxy": { "result": true, "type": "datacenter" }
}

Express middleware

Forwards the allowed headers plus the ones the signature covers. Call it once per request and keep the answer; never retry a signed request. If the call fails, let the request through or fall back to your own policy; do not treat an error as a bad bot.

const FORWARD = ['user-agent', 'accept', 'accept-language', 'accept-encoding', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user', 'signature', 'signature-input', 'signature-agent', 'upgrade-insecure-requests'];
// Header names covered by signature-input, e.g. sig1=("@authority" "signature-agent");created=...
const covered = value => [...String(value ?? '').matchAll(/\(([^)]*)\)/g)]
  .flatMap(m => [...m[1].matchAll(/"([^"]*)"/g)].map(x => x[1].toLowerCase()))
  .filter(name => name && !name.startsWith('@') && !/^(?:cookie|authorization)$/.test(name));

// req.ip is only the real client when Express trusts your proxy: set a hop count or CIDR, never true.
app.set('trust proxy', 1);

app.use(async (req, res, next) => {
  const names = new Set([...FORWARD, ...covered(req.headers['signature-input'])]);
  const headers = Object.fromEntries([...names].filter(h => req.headers[h]).map(h => [h, String(req.headers[h])]));
  const r = await fetch('https://api.fpchecker.com/v1/bot-check', {
    method: 'POST',
    headers: { 'content-type': 'application/json', 'X-Secret-Key': process.env.FPCHECKER_SECRET_KEY },
    body: JSON.stringify({ method: req.method, url: `https://${req.headers.host}${req.originalUrl}`, ip: req.ip, headers }),
  }).catch(() => null);
  const check = r?.ok ? await r.json() : null;
  if (check?.bot.result === 'bad') return res.status(403).end(); // your policy
  next();
});

Next.js middleware

The same call from middleware.ts. Take the client address from the header your ingress overwrites, never from one the client can choose.

import { NextResponse } from 'next/server';

const FORWARD = ['user-agent', 'accept', 'accept-language', 'accept-encoding', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user', 'signature', 'signature-input', 'signature-agent', 'upgrade-insecure-requests'];
// Header names covered by signature-input, e.g. sig1=("@authority" "signature-agent");created=...
const covered = value => [...String(value ?? '').matchAll(/\(([^)]*)\)/g)]
  .flatMap(m => [...m[1].matchAll(/"([^"]*)"/g)].map(x => x[1].toLowerCase()))
  .filter(name => name && !name.startsWith('@') && !/^(?:cookie|authorization)$/.test(name));

export async function middleware(request) {
  // The header your ingress overwrites. Never use the leftmost X-Forwarded-For: the client controls it.
  const ip = request.headers.get('cf-connecting-ip');
  const names = new Set([...FORWARD, ...covered(request.headers.get('signature-input'))]);
  const headers = Object.fromEntries([...names].flatMap(h => (request.headers.get(h) ? [[h, request.headers.get(h)]] : [])));
  const r = await fetch('https://api.fpchecker.com/v1/bot-check', {
    method: 'POST',
    headers: { 'content-type': 'application/json', 'X-Secret-Key': process.env.FPCHECKER_SECRET_KEY },
    body: JSON.stringify({ method: request.method, url: request.url, ip, headers }),
  }).catch(() => null);
  const check = r?.ok ? await r.json() : null;
  if (check?.bot.result === 'bad') return new NextResponse(null, { status: 403 }); // your policy
  return NextResponse.next();
}

Bot directory →Check your own request →

Rules and ruleAction

Rules turn what the server learned about a visit into a decision your project applies, without code on your side. A project has one ruleset: an on/off switch and an ordered list of up to 50 rules, edited in the console. A rule has a name, an enabled flag, match all or any, up to 10 conditions and one action: allow, human_check or deny. The first enabled rule that matches decides.

A condition is { field, op, value }. Fields cover the signals (vpn, proxy, tor, blocklisted, tampered, virtualMachine, privacyBrowser, incognito, bot, botResult, botCategory, botIdentity, botId, botTypes), the mobile signals (rooted, hooked, emulator, debugger, developerMode, mitm, locationSpoofed, clonedApp), ipKind (hosting, vpn, relay, tor, mobile or other), country, asn, velocityIps1h, velocityCountries24h, velocityLinkedIds24h, trustScore, identityConfidence, isNew, platform, linkedId and tag.<key>. incognito is true or false for a Chrome or Firefox desktop private window and null everywhere else, so a condition on it is false there. bot is true only for a bad bot; botId must be an id from the bot directory. A condition on a value the server does not have is false, including neq and not_in, so a rule never fires on a guess. The console always offers the current field list.

In /v1/eligibility, deny answers eligible: false with reason rule_deny and the ruleId. human_check requires the human check for the request. allow skips only the first-claim network and trust checks (in a shared-device class the human check still runs) and answers rule_allow with the ruleId; it cannot override claim limits: it never skips the device, email, household or domain limits, so a rule cannot hand one device a second trial. In /v1/quota, only deny applies. Treat rule_deny and rule_allow like any other reason, and keep tolerating reasons you do not know.

Every event of a project with the ruleset on carries ruleAction, in the events API and in the webhook body: { type, ruleId, ruleName, rulesetVersion }. type is allow, human_check, deny or none when no rule matched; ruleAction is null when the ruleset is off. It is never in the answer the browser or app receives, so the visitor does not learn which rule caught them.

Rules are evaluated when the device is identified, so a change applies to identifications made after you save it. Before switching rules on, run an unsaved list against a stored event in the console: it shows which rule would decide and which others also match, and saves nothing.

// event.ruleAction on GET /v1/events and in webhooks
{ type: 'deny', ruleId: 'r_k3x9a1bc', ruleName: 'Deny Tor', rulesetVersion: 3 }

// POST /v1/eligibility when a rule refused the claim
{ eligible: false, action: 'deny', reason: 'rule_deny', ruleId: 'r_k3x9a1bc' }

10 rule templates to start from →

Cloudflare proxy integration

You can serve the agent and the identify calls from your own domain with a small open-source Cloudflare Worker that you deploy in your own Cloudflare account. The Worker answers on yoursite.com/<path>/* (the default path is fpc) and forwards the challenge and identify routes, plus agent.js. Every other path is a 404. Its source and deploy steps are in integrations/cloudflare-worker of the open-source repository.

Create a proxy secret in the console (project, Cloudflare proxy). It is shown once; store it in the Worker as FPC_PROXY_SECRET. To use a path other than fpc, set FPC_PATH in the Worker; the route and the agent’s endpoint and scriptUrl must use the same path. A project has an active and a previous secret and accepts both, so you can rotate without downtime; delete the previous one when the Worker has the new value. Changes reach every server within about a minute.

The Worker adds X-FPC-Proxy-Secret and the visitor details (X-FPC-Client-IP, Country, Timezone, City, ASN and X-FPC-Forwarded-Host), and removes any X-FPC header a browser sent. Our servers use those details only from a caller with a valid secret, and then for the whole request: filters, rate limits, the event and its signals. Events report proxied: true. Without a secret the headers are ignored; a wrong secret answers 403 request_filtered, not billed. A valid secret without a visitor IP is refused.

Rate limiting: all proxied traffic arrives from the Worker’s egress addresses, so those get a loose circuit breaker first; once the secret verifies, per-visitor limits apply to the forwarded IP.

import { load } from '@fingerprintchecker/agent';

const fp = await load({
  endpoint: 'https://yoursite.com/fpc',
  scriptUrl: 'https://yoursite.com/fpc/agent.js',
});

Deploy steps for the Worker →

Sealed results

A copy of eventId or visitorId that travelled through the browser or app can be edited on the way. With sealed results on, a successful identify answer (web v2 and the native SDKs) also carries sealedResult: a small encrypted record that only FingerPrint Checker could have produced and only your backend can read. Opening it proves the answer came from us for this project and gives you the event it recorded, without another API call. It is part of identify and adds no charge.

What is inside: eventId, visitorId, projectId (check that it is yours), linkedId, tags, timestamp (Unix milliseconds), platform and proofStatus, plus a version number. It has no signals, no score, no decision and no IP address: read signals from the events API, and get the decision from eligibility.

To turn it on, open the project in the console and use Sealing keys. Creating a key shows its value once; afterwards the console shows only the key id and the creation time, so store it in your backend’s secret store at once. Sealing starts straight away. Deleting the active key turns sealing off again.

Rotation uses two keys, active and previous. Rotating makes a new key active and the old active key previous. Put the new key into your backend next to the old one, and pass both to the unseal call: blobs sealed with either keep opening. Delete previous only after the last blob sealed with it has been used. A deleted key can still seal for up to 60 seconds on another server process.

A sealed result proves origin, not entitlement: it is not a grant. Grant a trial, coupon or credit only through the eligibility call with a fresh attestation. Before you use a sealed result for anything else, check projectId is yours, reject a timestamp older than about 5 minutes, and treat eventId as single-use (remember the ones you have seen), or match its linkedId to the signed-in user.

Be ready for a missing sealedResult: sealing is best effort, and an identify can succeed without one. Fall back to what you already trust, such as GET /v1/events/{eventId} with your secret key or the visitorId in the eligibility answer. Treat a failure to open a blob as “not from FingerPrint Checker”, never as a result.

  • Node.js: unsealResult(sealedResult, [keyA, keyB]) from @fingerprintchecker/node.
  • Python: unseal_result(sealed_result, [key_a, key_b]) from fpchecker (pip install fingerprintchecker).
  • Go: fpchecker.UnsealResult(sealedResult, []string{keyA, keyB}).
import { unsealResult, SealedResultError } from '@fingerprintchecker/node';

// keys: every active key, base64 as shown once in the console (active and previous during a rotation).
const keys = [process.env.FPC_SEALED_KEY, process.env.FPC_SEALED_KEY_PREVIOUS].filter(Boolean);

try {
  const result = unsealResult(req.body.sealedResult, keys);
  if (result.projectId !== process.env.FPCHECKER_PROJECT_ID) throw new Error('not our project');
  if (Date.now() - result.timestamp > 5 * 60 * 1000) throw new Error('too old'); // timestamp is Unix milliseconds
  if (!(await rememberOnce(result.eventId))) throw new Error('eventId already used'); // your store; or match result.linkedId to the signed-in user
  // This proves origin only. To grant a trial, coupon or credit, call eligibility with a fresh attestation.
  // result: { version, eventId, visitorId, projectId, linkedId, tags, timestamp, platform, proofStatus }
} catch (err) {
  if (!(err instanceof SealedResultError)) throw err; // code: bad_format, unknown_key, decrypt_failed, invalid_key, unsupported_version
  // Not from FingerPrint Checker: refuse, or fall back to GET /v1/events/{eventId}.
}

Client-side result cache

The web agent package can keep a short-lived copy of the last identify result so a page that identifies the same user again does not call the API every time. It is off by default. Turn it on per call with cache: { ttlSeconds, storage }, where ttlSeconds is 1 to 3600 and storage is memory, session or local. Without cache, or with cache: false, every identify goes to the server.

A cached result belongs to an earlier identify. It returns eventId and sealedResult with fromCache: true and no attestation, so it can never be used for eligibility or quota. Never use a cached result for a decision about eligibility or quota: pass cache: false there to force a fresh request, with a new intent token.

Only eventId and sealedResult are stored, never the attestation. The key is built from your publishable key, linkedId and tags; the raw linkedId is not written to storage. An expired or malformed entry counts as a miss, and a storage error (private mode, blocked storage) also counts as a miss, so identify keeps working.

// Fine for showing a returning user a welcome screen: no decision depends on it.
const seen = await fp.identify({ intentToken, linkedId: userId, cache: { ttlSeconds: 300, storage: 'session' } });
if (seen.fromCache) {
  // seen.attestation is undefined here. Do not send this to /v1/eligibility or /v1/quota.
}

// Before any decision: force a fresh identify, then send its attestation to your backend.
const fresh = await fp.identify({ intentToken: newIntentToken, cache: false });

Server SDKs

Three server SDKs wrap the backend endpoints, so you do not write the HTTP calls yourself: @fingerprintchecker/node for Node.js 18 and later, fingerprintchecker (import name fpchecker) for Python 3.9 and later, and the Go module github.com/AndrewYukon/FingerPrintChecker/server/go. Each has one method per operation in the OpenAPI file (createIntent, checkEligibility, checkQuota, searchEvents and so on, in the naming style of the language), opens sealed results and verifies webhook signatures.

They send your secret key only in the X-Secret-Key header, never follow redirects, and retry only GET requests. A 2xx answer is returned as data, including a refusal such as ok: false, so always check the body. A non-2xx answer throws an API error that carries the status and the error code.

Install commands and a minimal example for every SDK, browser and mobile ones included, are on the SDK libraries page (/sdk-libraries).

import os
from fpchecker import FpChecker, FpCheckerApiError

fp = FpChecker(os.environ["FPCHECKER_SECRET_KEY"])
try:
    intent = fp.create_intent({"purpose": "claim:trial"})
except FpCheckerApiError as err:
    raise RuntimeError(f"intent failed: {err.status} {err.code}") from err
token = intent["intentToken"]  # give this to the web agent or the mobile SDK

Install lines and examples for every SDK →

Webhooks

Add up to 3 HTTPS endpoints per project in the console (Webhooks and retention). The URL must use https on port 443 and a public hostname, not an IP address. Every identify is POSTed to each enabled endpoint as the same event JSON the API returns ("v": 1). Use eventId to ignore duplicates.

The signing secret (whsec_…) is shown once when you add the endpoint and again only when you rotate it. Rotation takes effect immediately, with no grace period. Send test event posts a sample event marked "test": true.

Answer with any 2xx within 3 seconds. Other answers and timeouts are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (6 attempts in all), then marked failed. Redirects are not followed. An endpoint that fails every delivery for 3 days is disabled automatically; the console shows why and keeps the last 50 deliveries.

Each request carries FPC-Signature, FPC-Event-Id, FPC-Delivery-Id and User-Agent: FPChecker-Webhooks/1. Verify the signature on the raw body before you parse it.

Verify a webhook signature

FPC-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of t, a dot and the raw request body, keyed with your signing secret. Reject the request unless the signature matches (compare in constant time) and t is within 5 minutes of now. Use the body exactly as received, not re-serialised JSON.

Node.js

import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the request body exactly as received (Buffer or string).
export function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(header ?? '').split(',').map(p => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(parts.t + '.').update(rawBody).digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Python

import hashlib
import hmac
import time


def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Go

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

func VerifyWebhook(rawBody []byte, header, secret string, now time.Time) bool {
	var t, v1 string
	for _, part := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(part, "="); ok {
			switch k {
			case "t":
				t = v
			case "v1":
				v1 = v
			}
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil {
		return false
	}
	if d := now.Unix() - ts; d > 300 || d < -300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	given, err := hex.DecodeString(v1)
	return err == nil && hmac.Equal(given, mac.Sum(nil))
}

Verify a webhook with an SDK

Each server SDK ships the check described above: pass the raw request bytes, the FPC-Signature header and your whsec_ secret. The check rejects a wrong or malformed signature and a timestamp more than 5 minutes from your clock (adjustable). Use the exact bytes you received, not a re-serialised object.

  • Node.js: verifyWebhookSignature({ rawBody, header, secret }) throws WebhookSignatureError.
  • Python: verify_webhook_signature(raw_body, header, secret) raises WebhookSignatureError.
  • Go: fpchecker.VerifyWebhookSignature(rawBody, header, secret, 5*time.Minute, time.Now()) returns an error.

OpenAPI file

The customer-facing API is described in an OpenAPI 3.1 file: identify, intents, eligibility, quota, subjects, feedback, claim flags, events, visitors, health, status and the webhook payload. Each operation has an operationId that the server SDKs use as the method name. It lists no admin or console routes.

Download it from /developers/openapi.yaml and load it in your own tooling or client generator. The production API does not serve /openapi.json or /docs: this file is the contract, and the copy here names the API release it came from in its first line.

Download openapi.yaml

Management API

The Management API lets your own tooling do what you would otherwise click through in the console. It acts on the whole account, unlike a project’s secret key, which works on one project. Every route is under https://api.fpchecker.com/v1/management/ and takes a management key (mk_…) as a bearer token.

Owners and admins create management keys in the console at /console/api-keys. A key is shown once, has a read or write scope, and can expire after 30, 90 or 365 days. A read key can call every GET route and the ruleset dry run; a write key can also create, change, rotate and delete. A key sees only its own account’s projects, and a project that is not yours answers exactly like one that does not exist.

Rate limits are 120 reads and 30 writes a minute per account, shared by all keys. Over the limit is a 429 with Retry-After: 60, and a 429 changes nothing, so retrying is safe.

What it does: projects and environments, key rotation, webhooks and rules, plus reading usage, the audit log, events and visitors. It cannot buy credits, manage members or create management keys. Those stay in the console, and a request for them answers 404. Changes made with a management key are recorded in the account audit log with the key as the actor.

The full reference, with every route and answer, is the OpenAPI file below (tag Management). To use the API from a terminal or an AI assistant instead of writing HTTP calls, see Build with AI.

  • GET /v1/management/account, /usage and /audit: status and credit balance, billed calls per day, project and endpoint, and the audit log (newest first).
  • GET and POST /v1/management/projects, then GET, PATCH and DELETE on /projects/{id}: list, create (the new keys are in the answer only), read, change and delete a never-used project.
  • POST /projects/{id}/rotate, POST /projects/{id}/environments and POST /projects/{id}/copy-settings: replace a key, add an environment such as staging, promote tested settings between environments.
  • GET and POST /projects/{id}/webhooks, with PATCH, DELETE and rotate on a single endpoint; GET and PUT /projects/{id}/ruleset (a stale save is 409 version_conflict) and POST /projects/{id}/ruleset/test for a dry run.
  • GET /projects/{id}/events, /events/{eventId} and /visitors/{visitorId}: the same events and filters as GET /v1/events.
curl https://api.fpchecker.com/v1/management/projects   -H "Authorization: Bearer $FPCHECKER_MANAGEMENT_KEY"
# Authorization: Bearer mk_…   (a failed key is always { "ok": false, "error": "invalid_management_key" })

Build with AI: CLI and MCP server →Download openapi.yaml

API reference

Base URL https://api.fpchecker.com. Backend calls send X-Secret-Key; /v2/intents is a backend call too; the agent and SDKs send the publishable key.

POST /v2/intentsCreate the intent token for one identify, with your secret key (free, rate limited).
POST /v1/eligibilityOne decision for a sign-up, trial, coupon or referral claim (billed).
POST /v1/eligibility/releaseGive a claim back, e.g. after a refund (billed).
GET /v1/claims/flagsClaims found later to share a household with an earlier claim, page by page with after and limit (free, 60 a minute).
POST /v1/quotaAnonymous usage limits per device, without an email (billed).
POST /v1/feedbackLabel a visitor or claim as abuse or legit, to improve future scoring (free).
POST /v2/identifyCalled by the agent and SDKs with the publishable key and an intent token (free, daily cap). /v1/identify is the legacy seal-key version.
POST /v1/bot-checkClassify a plain HTTP request from your backend without the browser script (free, 1,200 a minute per project; see Bot check).
GET /v1/eventsSearch events by visitorId, linkedId, ip, suspect, platform, time and the signal filters bot, vpn, proxy, tor, incognito, tampering, virtualMachine, rooted, hooked, emulator, mitm, locationSpoofed and clonedApp, and bot_result, bot_category, bot_identity and bot_id, page by page with cursor (free, rate limited).
GET /v1/events/{eventId}One event (free, rate limited).
PATCH /v1/events/{eventId}Set linkedId, tags or suspect on an event (free, rate limited).
GET /v1/visitors/{visitorId}First and last seen, event count, recent linkedIds and events of a visitor in this project (free, rate limited).
DELETE /v1/visitors/{visitorId}Erase this project’s events and device records for a visitor (free, limited to 10 a minute).
GET /statusPublic health of the API: 200 when every component is up, 503 otherwise.

Errors, limits and billing

  • Most failures still answer HTTP 200 and fail closed: eligibility returns eligible: false with action deny. Always branch on the body, not only the status code.
  • 401: missing or wrong key, or a publishable key used on a backend endpoint. 402: no credits left. 403: project disabled, or request_filtered when your own request filters (or a wrong proxy secret) refused it. 429: rate limited or daily write budget reached.
  • Only calls that return a decision are billed. identify, feedback, the events API and every error response are free.

Resources